mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 446479791e | |||
| 99af59a914 | |||
| b77bd26874 | |||
| 956c1cf027 | |||
| 8b9d44110e | |||
| 44ce8d114f | |||
| 769b0d7578 | |||
| 03e775947d | |||
| 25e73d3bd6 | |||
| a464b45367 |
@@ -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,10 +88,21 @@ 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'
|
||||
|
||||
# These handler/store fixtures and the relocated-pool GET compose deep storage
|
||||
# futures in libtest threads; on Linux they overflow the default 2 MiB stack.
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs) & (test(/^admin::handlers::table_catalog::tests::/) | test(/^table_catalog::store::tests::/) | test(=app::object::get::tests::execute_get_object_resumes_from_relocated_pool_without_splicing_body))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-stack'
|
||||
@@ -333,10 +344,19 @@ 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'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs) & (test(/^admin::handlers::table_catalog::tests::/) | test(/^table_catalog::store::tests::/) | test(=app::object::get::tests::execute_get_object_resumes_from_relocated_pool_without_splicing_body))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||
setup = 'lifecycle-large-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 }}
|
||||
|
||||
+67
-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,64 @@ 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'
|
||||
shell: bash
|
||||
run: |
|
||||
binary="target/${{ matrix.target }}/release/rustfs"
|
||||
if otool -L "$binary" | grep -E 'liblzma.*\.dylib'; then
|
||||
echo "macOS release binary must not depend on a dynamic liblzma library" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$binary" --version
|
||||
|
||||
- name: Generate the CPU symbol catalog
|
||||
if: endsWith(matrix.target, '-linux-gnu') && matrix.features == 'pyroscope'
|
||||
@@ -543,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)
|
||||
@@ -660,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: |
|
||||
@@ -668,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
|
||||
@@ -680,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
+359
-316
File diff suppressed because it is too large
Load Diff
+76
-75
@@ -74,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.2"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||
@@ -91,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.2" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.2" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.2" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.2" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.2" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.2" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.2" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.2" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.2" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.2" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.2" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.2" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.2" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.2" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.2" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.2" }
|
||||
rustfs-license = { path = "crates/license", version = "1.0.2" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.2" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.2" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.2" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.2" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.2" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.2" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.2" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.2" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.2", default-features = false }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.2" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.2" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.2" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.2" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.2" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.2" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.2" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.2" }
|
||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.2" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.2" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.2" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.2" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.2" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.2" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.2" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.2" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.2" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.2" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.2" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.2" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.2" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.2" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.2" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.2" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
@@ -156,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"
|
||||
@@ -168,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" }
|
||||
@@ -180,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",
|
||||
@@ -200,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
|
||||
@@ -224,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"
|
||||
@@ -268,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"
|
||||
@@ -319,14 +320,14 @@ 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" }
|
||||
@@ -340,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"
|
||||
@@ -353,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"
|
||||
@@ -383,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 = []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -422,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,
|
||||
@@ -519,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,
|
||||
@@ -590,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
|
||||
@@ -2939,6 +2949,20 @@ fn resolve_tier_free_version_recovery_enabled(value: Result<String, env::VarErro
|
||||
}
|
||||
|
||||
fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<()>) -> Option<JoinHandle<()>> {
|
||||
spawn_tier_free_version_recovery_once_with_options(
|
||||
api,
|
||||
started,
|
||||
TIER_FREE_VERSION_RECOVERY_LEADER_LOCK.to_string(),
|
||||
tier_free_version_recovery_leader_retry_delay,
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_tier_free_version_recovery_once_with_options(
|
||||
api: Arc<ECStore>,
|
||||
started: &OnceLock<()>,
|
||||
leader_lock_name: String,
|
||||
retry_delay: fn() -> StdDuration,
|
||||
) -> Option<JoinHandle<()>> {
|
||||
if !tier_free_version_recovery_enabled() {
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_WORKER_STATE,
|
||||
@@ -2956,15 +2980,15 @@ fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<(
|
||||
Some(tokio::spawn(async move {
|
||||
let cancel_token = api.ctx.background_cancel_token().unwrap_or_default();
|
||||
let acquire_api = Arc::clone(&api);
|
||||
let acquire_lock_name = Arc::<str>::from(leader_lock_name);
|
||||
run_tier_free_version_recovery_coordinator(
|
||||
cancel_token,
|
||||
tier_free_version_recovery_leader_retry_delay,
|
||||
retry_delay,
|
||||
move || {
|
||||
let api = Arc::clone(&acquire_api);
|
||||
let lock_name = Arc::clone(&acquire_lock_name);
|
||||
async move {
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, TIER_FREE_VERSION_RECOVERY_LEADER_LOCK)
|
||||
.await?;
|
||||
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &lock_name).await?;
|
||||
match lock.get_write_lock_quiet(TIER_FREE_VERSION_RECOVERY_LOCK_TIMEOUT).await {
|
||||
Ok(guard) => Ok(Some(guard)),
|
||||
Err(LockError::Timeout { .. }) => Ok(None),
|
||||
@@ -4906,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)),
|
||||
};
|
||||
}
|
||||
@@ -4916,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) => {
|
||||
@@ -5788,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,
|
||||
@@ -6027,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};
|
||||
@@ -10497,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();
|
||||
@@ -13371,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(),
|
||||
@@ -14193,6 +14270,10 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
fn fast_tier_free_version_recovery_leader_retry_delay() -> StdDuration {
|
||||
StdDuration::from_millis(10)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
|
||||
@@ -14208,8 +14289,13 @@ mod tests {
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
|
||||
|
||||
let started = OnceLock::new();
|
||||
let recovery = super::spawn_tier_free_version_recovery_once(Arc::clone(&ecstore), &started)
|
||||
.expect("production recovery entrypoint should start once");
|
||||
let recovery = super::spawn_tier_free_version_recovery_once_with_options(
|
||||
Arc::clone(&ecstore),
|
||||
&started,
|
||||
format!("tier-free-version-recovery/test/{}", Uuid::new_v4()),
|
||||
fast_tier_free_version_recovery_leader_retry_delay,
|
||||
)
|
||||
.expect("production recovery entrypoint should start once");
|
||||
let task = tokio::time::timeout(StdDuration::from_secs(30), async { recovery_rx.lock().await.recv().await })
|
||||
.await
|
||||
.expect("production recovery entrypoint should enqueue the seeded item")
|
||||
|
||||
@@ -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,
|
||||
@@ -1292,14 +1356,34 @@ pub(crate) async fn get_object_lock_config_state_in(
|
||||
pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
|
||||
get_object_lock_config_and_incarnation_from_disk_with_guard_in(ctx, bucket, None).await
|
||||
}
|
||||
|
||||
/// Bucket deletion already owns the namespace/lifecycle fences. Reuse its
|
||||
/// live guard rather than recursively acquiring the bucket namespace lock.
|
||||
/// The caller must also hold the metadata transaction read lock.
|
||||
pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_with_guard_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
guard: Option<&rustfs_lock::NamespaceLockGuard>,
|
||||
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
|
||||
match bucket_meta_sys
|
||||
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
|
||||
.await?
|
||||
{
|
||||
let authority = match guard {
|
||||
Some(guard) => {
|
||||
bucket_meta_sys
|
||||
.read_authoritative_metadata_from_disk_under_guard(bucket, guard)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
bucket_meta_sys
|
||||
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
match authority {
|
||||
BucketMetadataAuthority::Authoritative(metadata)
|
||||
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
||||
{
|
||||
@@ -1403,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,
|
||||
@@ -1417,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;
|
||||
@@ -2932,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?;
|
||||
|
||||
@@ -4836,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",
|
||||
|
||||
@@ -448,6 +448,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 +1866,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 +3597,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 +3615,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
|
||||
@@ -6675,6 +6719,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 +6896,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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2911,7 +3133,7 @@ mod tests {
|
||||
|
||||
let timed = wrapper.track_disk_health_with_op(
|
||||
"write_all",
|
||||
|| std::future::pending::<Result<()>>(),
|
||||
std::future::pending::<Result<()>>,
|
||||
SLOW_DISK_OPERATION_THRESHOLD + Duration::from_secs(1),
|
||||
);
|
||||
tokio::pin!(timed);
|
||||
@@ -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
-319
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)
|
||||
|
||||
@@ -442,12 +442,19 @@ impl WritePlan {
|
||||
}
|
||||
|
||||
if let Some(encryption) = self.encryption {
|
||||
let etag_nonce = match encryption.mode {
|
||||
WriteEncryptionMode::Singlepart { base_nonce } | WriteEncryptionMode::MultipartLegacy { base_nonce, .. } => {
|
||||
base_nonce
|
||||
}
|
||||
WriteEncryptionMode::SinglepartObjectKey | WriteEncryptionMode::MultipartObjectKey { .. } => [0u8; 12],
|
||||
};
|
||||
let protected_reader = rustfs_rio::EtagReader::new_encrypted(reader, encryption.key_bytes, etag_nonce)?;
|
||||
reader = match encryption.mode {
|
||||
WriteEncryptionMode::SinglepartObjectKey => HashReader::from_reader(
|
||||
#[cfg(feature = "rio-v2")]
|
||||
EncryptReader::new_with_object_key(reader, encryption.key_bytes),
|
||||
EncryptReader::new_with_object_key(protected_reader, encryption.key_bytes),
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
EncryptReader::new(reader, encryption.key_bytes, [0u8; 12]),
|
||||
EncryptReader::new(protected_reader, encryption.key_bytes, [0u8; 12]),
|
||||
HashReader::SIZE_PRESERVE_LAYER,
|
||||
actual_size,
|
||||
None,
|
||||
@@ -457,12 +464,12 @@ impl WritePlan {
|
||||
WriteEncryptionMode::Singlepart { base_nonce } => {
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
let encrypt_reader = if encryption_frame_v2_enabled() {
|
||||
EncryptReader::new_v2(reader, encryption.key_bytes, base_nonce)
|
||||
EncryptReader::new_v2(protected_reader, encryption.key_bytes, base_nonce)
|
||||
} else {
|
||||
EncryptReader::new(reader, encryption.key_bytes, base_nonce)
|
||||
EncryptReader::new(protected_reader, encryption.key_bytes, base_nonce)
|
||||
};
|
||||
#[cfg(feature = "rio-v2")]
|
||||
let encrypt_reader = EncryptReader::new(reader, encryption.key_bytes, base_nonce);
|
||||
let encrypt_reader = EncryptReader::new(protected_reader, encryption.key_bytes, base_nonce);
|
||||
HashReader::from_reader(encrypt_reader, HashReader::SIZE_PRESERVE_LAYER, actual_size, None, None, false)?
|
||||
}
|
||||
WriteEncryptionMode::MultipartLegacy {
|
||||
@@ -471,20 +478,25 @@ impl WritePlan {
|
||||
} => {
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
let encrypt_reader = if encryption_frame_v2_enabled() {
|
||||
EncryptReader::new_multipart_v2(reader, encryption.key_bytes, base_nonce, multipart_part_number)
|
||||
EncryptReader::new_multipart_v2(protected_reader, encryption.key_bytes, base_nonce, multipart_part_number)
|
||||
} else {
|
||||
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number)
|
||||
EncryptReader::new_multipart(protected_reader, encryption.key_bytes, base_nonce, multipart_part_number)
|
||||
};
|
||||
#[cfg(feature = "rio-v2")]
|
||||
let encrypt_reader =
|
||||
EncryptReader::new_multipart(reader, encryption.key_bytes, base_nonce, multipart_part_number);
|
||||
EncryptReader::new_multipart(protected_reader, encryption.key_bytes, base_nonce, multipart_part_number);
|
||||
HashReader::from_reader(encrypt_reader, HashReader::SIZE_PRESERVE_LAYER, actual_size, None, None, false)?
|
||||
}
|
||||
WriteEncryptionMode::MultipartObjectKey { multipart_part_number } => HashReader::from_reader(
|
||||
#[cfg(feature = "rio-v2")]
|
||||
EncryptReader::new_multipart_with_object_key(reader, encryption.key_bytes, multipart_part_number),
|
||||
EncryptReader::new_multipart_with_object_key(protected_reader, encryption.key_bytes, multipart_part_number),
|
||||
#[cfg(not(feature = "rio-v2"))]
|
||||
EncryptReader::new_multipart(reader, encryption.key_bytes, [0u8; 12], multipart_part_number as usize),
|
||||
EncryptReader::new_multipart(
|
||||
protected_reader,
|
||||
encryption.key_bytes,
|
||||
[0u8; 12],
|
||||
multipart_part_number as usize,
|
||||
),
|
||||
HashReader::SIZE_PRESERVE_LAYER,
|
||||
actual_size,
|
||||
None,
|
||||
@@ -497,6 +509,7 @@ impl WritePlan {
|
||||
// `ignore_value` deliberately avoids a second hasher over compressed or
|
||||
// encrypted bytes. The inner reader still validates the plaintext request
|
||||
// checksum while this outer reader exposes the request checksum context.
|
||||
reader.encrypted_etag = encrypted;
|
||||
reader.add_non_trailing_checksum(checksum, true)?;
|
||||
reader.set_trailer(trailer);
|
||||
|
||||
@@ -513,6 +526,36 @@ mod tests {
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_plan_encryption_persists_protected_etags_for_every_mode() {
|
||||
let data = b"Salary 2026: 85000 EUR";
|
||||
let size = i64::try_from(data.len()).expect("test data size");
|
||||
let mut control =
|
||||
HashReader::from_stream(Cursor::new(data), size, size, None, None, false).expect("create control reader");
|
||||
control.read_to_end(&mut Vec::new()).await.expect("read control plaintext");
|
||||
let md5 = control.try_resolve_etag().expect("control MD5");
|
||||
for encryption in [
|
||||
WriteEncryption::singlepart([1; 32], [1; 12]),
|
||||
WriteEncryption::singlepart_object_key([1; 32]),
|
||||
WriteEncryption::multipart([1; 32], [1; 12], 1),
|
||||
WriteEncryption::multipart_object_key([1; 32], 1),
|
||||
] {
|
||||
let plaintext = HashReader::from_stream(Cursor::new(data), size, size, Some(md5.clone()), None, false)
|
||||
.expect("create checked reader");
|
||||
let mut reader = WritePlan::new()
|
||||
.with_encryption(encryption)
|
||||
.apply(plaintext, size)
|
||||
.expect("apply encryption");
|
||||
let mut ciphertext = Vec::new();
|
||||
reader.read_to_end(&mut ciphertext).await.expect("stream ciphertext");
|
||||
assert_ne!(ciphertext, data);
|
||||
let tag = reader.try_resolve_etag().expect("resolve persisted encrypted ETag");
|
||||
assert_ne!(tag, md5, "stored ETag must not reveal the plaintext MD5");
|
||||
assert_eq!(tag.len(), 32, "multipart ETags require a 16-byte hexadecimal part tag");
|
||||
assert_eq!(reader.try_resolve_etag().as_ref(), Some(&tag));
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_non_trailing_checksum_survives(plan: WritePlan) {
|
||||
let plaintext = b"checksum-context-through-write-plan".repeat(256);
|
||||
let actual_size = plaintext.len() as i64;
|
||||
|
||||
@@ -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
|
||||
@@ -1158,6 +1162,7 @@ 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("replication_request", &self.replication_request)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3004,6 +3004,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
||||
// etag
|
||||
let etag = resolve_complete_etag(opts, &uploaded_parts);
|
||||
|
||||
// An upload can contain parts from an older writer or a peer. Until
|
||||
// every part's format is attested, keep its ETag out of keyless lists.
|
||||
rustfs_utils::http::metadata_compat::remove_str(&mut fi.metadata, rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG);
|
||||
|
||||
fi.metadata.insert("etag".to_owned(), etag);
|
||||
|
||||
let persist_encryption_original_size = should_persist_encryption_original_size(&fi.metadata);
|
||||
|
||||
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};
|
||||
@@ -901,6 +902,7 @@ impl ECStore {
|
||||
}
|
||||
};
|
||||
let confirmed_missing = existing_bucket_info.is_none();
|
||||
let mut pending_creation_metadata = None;
|
||||
if confirmed_missing
|
||||
&& !opts.no_lock
|
||||
&& !is_meta_bucketname(bucket)
|
||||
@@ -921,13 +923,25 @@ impl ECStore {
|
||||
),
|
||||
other => other,
|
||||
})?;
|
||||
return Err(StorageError::InvalidArgument(
|
||||
"CreateBucket".to_string(),
|
||||
"bucket".to_string(),
|
||||
format!("orphaned bucket generation {old_incarnation} requires administrator recovery before recreation"),
|
||||
));
|
||||
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
|
||||
let metadata_store = metadata_sys::object_store_in(&self.ctx).await?;
|
||||
let pending_intent = persisted
|
||||
&& metadata.bucket_incarnation_id == old_incarnation
|
||||
&& metadata.needs_bucket_creation_commit()
|
||||
&& !crate::bucket::retirement::is_retired(metadata_store, bucket, old_incarnation).await?;
|
||||
if pending_intent {
|
||||
pending_creation_metadata = Some(metadata);
|
||||
} else {
|
||||
return Err(StorageError::InvalidArgument(
|
||||
"CreateBucket".to_string(),
|
||||
"bucket".to_string(),
|
||||
format!("orphaned bucket generation {old_incarnation} requires administrator recovery before recreation"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let existing_metadata = if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) {
|
||||
let existing_metadata = if let Some(metadata) = pending_creation_metadata {
|
||||
Some(metadata)
|
||||
} else if opts.force_create && !confirmed_missing && !is_meta_bucketname(bucket) {
|
||||
let (mut metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
|
||||
if !persisted {
|
||||
metadata = BucketMetadata::new(bucket);
|
||||
@@ -1138,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)
|
||||
}))
|
||||
@@ -1218,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;
|
||||
@@ -1347,6 +1397,23 @@ impl ECStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Match metadata migration: lifecycle -> metadata transaction ->
|
||||
// bucket namespace. Holding the transaction while waiting for the
|
||||
// namespace prevents a migration writer and force delete from
|
||||
// acquiring these two locks in opposite orders.
|
||||
let object_lock_metadata_transaction = if opts.force && !is_meta_bucketname(bucket) {
|
||||
Some(
|
||||
await_bucket_namespace_operation(
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
bucket,
|
||||
"Object Lock metadata transaction acquisition",
|
||||
crate::bucket::metadata_sys::acquire_bucket_metadata_transaction_read_lock_in(&self.ctx, bucket),
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ns_guard = if !opts.no_lock {
|
||||
let ns_lock = self.new_ns_lock(bucket, bucket).await?;
|
||||
Some(
|
||||
@@ -1402,6 +1469,29 @@ impl ECStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep configuration changes fenced until data deletion finishes;
|
||||
// a force delete cannot bypass any Object Lock protection.
|
||||
let object_lock_metadata_guard = if bucket_exists && let Some(guard) = object_lock_metadata_transaction {
|
||||
let (state, incarnation, _) =
|
||||
crate::bucket::metadata_sys::get_object_lock_config_and_incarnation_from_disk_with_guard_in(
|
||||
&self.ctx,
|
||||
bucket,
|
||||
Some(ns_guard.as_ref().unwrap_or(&guard)),
|
||||
)
|
||||
.await?;
|
||||
match state {
|
||||
crate::bucket::metadata_sys::ObjectLockConfigState::ConfirmedAbsent => Some((guard, incarnation)),
|
||||
crate::bucket::metadata_sys::ObjectLockConfigState::Configured { .. } => {
|
||||
return Err(StorageError::PrefixAccessDenied(bucket.to_owned(), String::new()));
|
||||
}
|
||||
crate::bucket::metadata_sys::ObjectLockConfigState::Fabricated => {
|
||||
return Err(StorageError::FileCorrupt);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if sr_delete && !bucket_exists {
|
||||
delete_opts.force_if_empty = true;
|
||||
}
|
||||
@@ -1415,7 +1505,12 @@ impl ECStore {
|
||||
ns_guard.as_ref(),
|
||||
bucket,
|
||||
"bucket delete marker creation",
|
||||
self.mark_bucket_deleted(bucket),
|
||||
await_bucket_namespace_operation(
|
||||
object_lock_metadata_guard.as_ref().map(|(guard, _)| guard),
|
||||
bucket,
|
||||
"Object Lock configuration fence during bucket retirement",
|
||||
self.mark_bucket_deleted(bucket),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -1424,9 +1519,13 @@ impl ECStore {
|
||||
// Legacy buckets without a stamp cannot produce retirement authority.
|
||||
let retirement = if bucket_exists && bucket_lifecycle_guard.is_some() && !is_meta_bucketname(bucket) {
|
||||
if let Some(store) = metadata_sys::object_store_if_initialized_in(&self.ctx).await {
|
||||
crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket)
|
||||
.await?
|
||||
.map(|incarnation| (store, incarnation))
|
||||
if let Some((_, incarnation)) = object_lock_metadata_guard.as_ref() {
|
||||
Some((store, *incarnation))
|
||||
} else {
|
||||
crate::bucket::metadata::load_bucket_incarnation(store.clone(), bucket)
|
||||
.await?
|
||||
.map(|incarnation| (store, incarnation))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1438,13 +1537,19 @@ impl ECStore {
|
||||
bucket_lifecycle_guard.as_ref(),
|
||||
bucket,
|
||||
"physical bucket deletion",
|
||||
run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async {
|
||||
self.delete_bucket_on_sets(bucket, &delete_opts)
|
||||
.await
|
||||
.map_err(|err| to_object_err(err, vec![bucket]))
|
||||
}),
|
||||
await_bucket_namespace_operation(
|
||||
object_lock_metadata_guard.as_ref().map(|(guard, _)| guard),
|
||||
bucket,
|
||||
"Object Lock configuration fence during bucket deletion",
|
||||
run_physical_bucket_deletion(ns_guard.as_ref(), bucket, async {
|
||||
self.delete_bucket_on_sets(bucket, &delete_opts)
|
||||
.await
|
||||
.map_err(|err| to_object_err(err, vec![bucket]))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
drop(object_lock_metadata_guard);
|
||||
if let Err(err) = delete_result
|
||||
&& (!sr_delete || !is_err_strict_volume_not_found(&err))
|
||||
{
|
||||
@@ -2156,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));
|
||||
@@ -3475,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() {
|
||||
@@ -3689,6 +3925,116 @@ mod tests {
|
||||
assert!(!any_disk_path_exists(&disk_paths, bucket_deleted_marker_volume(&bucket)).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn force_bucket_delete_preserves_object_lock_versions() {
|
||||
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4], Some(2)).await;
|
||||
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("force-delete-worm-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(
|
||||
&bucket,
|
||||
&MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
versioning_enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("create Object Lock bucket");
|
||||
let until = (OffsetDateTime::now_utc() + time::Duration::days(365))
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.expect("format future retention date");
|
||||
let written = store
|
||||
.put_object(
|
||||
&bucket,
|
||||
"retained",
|
||||
&mut PutObjReader::from_vec(b"retained bytes".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
user_defined: HashMap::from([
|
||||
("x-amz-object-lock-mode".to_owned(), "COMPLIANCE".to_owned()),
|
||||
("x-amz-object-lock-retain-until-date".to_owned(), until),
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("write retained object");
|
||||
for operation in [SRBucketDeleteOp::NoOp, SRBucketDeleteOp::MarkDelete, SRBucketDeleteOp::Purge] {
|
||||
let err = store
|
||||
.delete_bucket(
|
||||
&bucket,
|
||||
&DeleteBucketOptions {
|
||||
force: true,
|
||||
srdelete_op: operation,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("force deletion must not bypass Object Lock");
|
||||
assert!(matches!(err, StorageError::PrefixAccessDenied(_, _)), "unexpected denial: {err}");
|
||||
let info = store
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
"retained",
|
||||
&ObjectOptions {
|
||||
version_id: written.version_id.map(|version| version.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("retained version must survive the rejected bucket deletion");
|
||||
assert_eq!(info.etag, written.etag);
|
||||
assert_eq!(info.size, written.size);
|
||||
assert_eq!(info.version_id, written.version_id);
|
||||
}
|
||||
metadata_sys::inject_object_lock_disk_read_error_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("inject authoritative metadata read failure");
|
||||
let error = store
|
||||
.delete_bucket(
|
||||
&bucket,
|
||||
&DeleteBucketOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("an unreadable Object Lock configuration must fail closed");
|
||||
assert!(error.to_string().contains("injected Object Lock metadata disk read failure"));
|
||||
let info = store
|
||||
.get_object_info(&bucket, "retained", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("metadata read failure must leave the retained object intact");
|
||||
assert_eq!(info.version_id, written.version_id);
|
||||
|
||||
let unlocked = format!("force-delete-unlocked-{}", Uuid::new_v4().simple());
|
||||
store
|
||||
.make_bucket(&unlocked, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create unlocked control bucket");
|
||||
store
|
||||
.put_object(
|
||||
&unlocked,
|
||||
"ordinary",
|
||||
&mut PutObjReader::from_vec(b"ordinary bytes".to_vec()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("write ordinary control object");
|
||||
store
|
||||
.delete_bucket(
|
||||
&unlocked,
|
||||
&DeleteBucketOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("force deletion must still work for an unlocked bucket");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn bucket_delete_mark_delete_rejects_hidden_object_paths_without_force() {
|
||||
|
||||
@@ -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));
|
||||
@@ -9567,8 +9594,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 +10077,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 +10843,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 +11011,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 +11091,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 +17426,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 +17511,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 +17576,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 +17650,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
|
||||
@@ -18318,7 +18438,7 @@ mod tests {
|
||||
let bucket = format!("lock-create-intent-retry-{}", uuid::Uuid::new_v4());
|
||||
let mut intent = crate::bucket::metadata::BucketMetadata::new(&bucket);
|
||||
intent.lock_enabled = true;
|
||||
crate::bucket::metadata_sys::set_new_bucket_metadata_in(&ctx, intent)
|
||||
crate::bucket::metadata_sys::set_new_bucket_metadata_intent_in(&ctx, intent)
|
||||
.await
|
||||
.expect("persist pre-visibility lock intent");
|
||||
assert!(
|
||||
@@ -22023,7 +22143,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 +22186,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 +22212,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
|
||||
|
||||
@@ -134,6 +134,18 @@ pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
{
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
if matches!(format_quorum, Some(Err(Error::ErasureReadQuorum)))
|
||||
&& let Some((format, members)) = select_existing_format_read_quorum(&formats, &errs, set_drive_count)
|
||||
{
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &members, set_drive_count).await?;
|
||||
// Loading an existing format grants no authority to initialize pool
|
||||
// metadata or migrate formats. The persisted storage classes and each
|
||||
// object's EC metadata still determine service and operation quorums.
|
||||
return Ok(LoadedFormat {
|
||||
format,
|
||||
pool_meta_bootstrap_authority: PoolMetaBootstrapAuthority::None,
|
||||
});
|
||||
}
|
||||
let resumable_partial_migration = formats_present > 0
|
||||
&& errs.iter().any(|error| matches!(error, Some(DiskError::UnformattedDisk)))
|
||||
&& format_quorum.as_ref().is_some_and(Result::is_err)
|
||||
@@ -578,6 +590,43 @@ pub fn get_format_erasure_in_quorum(formats: &[Option<FormatV3>], slot_offset: u
|
||||
select_format_erasure_in_quorum(formats, slot_offset).map(|(format, _)| format)
|
||||
}
|
||||
|
||||
/// Load a consistent existing layout with enough members per set to read the
|
||||
/// max-parity system metadata containing the persisted storage classes. This
|
||||
/// is not object-read admission: STANDARD, RRS and historical objects can have
|
||||
/// different EC layouts, which are validated after this bootstrap read.
|
||||
/// Format migration and healing still use strict-majority election.
|
||||
fn select_existing_format_read_quorum(
|
||||
formats: &[Option<FormatV3>],
|
||||
errors: &[Option<DiskError>],
|
||||
set_drive_count: usize,
|
||||
) -> Option<(FormatV3, Vec<bool>)> {
|
||||
if set_drive_count == 0 || formats.is_empty() || formats.len() != errors.len() {
|
||||
return None;
|
||||
}
|
||||
let metadata_parity = set_drive_count / 2;
|
||||
let metadata_read_quorum = set_drive_count - metadata_parity;
|
||||
let reference = formats.iter().flatten().next()?;
|
||||
if !formats_match_reference_slots(formats, reference, 0)
|
||||
|| check_format_erasure_value_for_topology(reference, formats.len(), set_drive_count).is_err()
|
||||
|| formats.iter().zip(errors).any(|(format, error)| match (format, error) {
|
||||
(Some(_), None) => false,
|
||||
(None, Some(DiskError::DiskNotFound)) => false,
|
||||
(None, Some(error)) => !is_network_like_disk_error(error),
|
||||
_ => true,
|
||||
})
|
||||
|| formats
|
||||
.chunks(set_drive_count)
|
||||
.any(|set| set.iter().flatten().count() < metadata_read_quorum)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut format = reference.clone();
|
||||
format.erasure.this = Uuid::nil();
|
||||
format.disk_info = None;
|
||||
Some((format, formats.iter().map(Option::is_some).collect()))
|
||||
}
|
||||
|
||||
pub(crate) fn select_format_erasure_in_quorum(formats: &[Option<FormatV3>], slot_offset: usize) -> Result<(FormatV3, Vec<bool>)> {
|
||||
let mut candidates = HashMap::new();
|
||||
let formats_present = formats.iter().flatten().count();
|
||||
@@ -1120,6 +1169,190 @@ mod tests {
|
||||
assert!(!formats_match_reference_slots(&formats, &reference, 0));
|
||||
}
|
||||
|
||||
fn half_pool_formats() -> (Vec<Option<FormatV3>>, Vec<Option<DiskError>>) {
|
||||
let reference = FormatV3::new(6, 4);
|
||||
let formats = (0..24)
|
||||
.map(|slot| {
|
||||
(slot % 4 < 2).then(|| {
|
||||
let mut format = reference.clone();
|
||||
format.erasure.this = reference.erasure.sets[slot / 4][slot % 4];
|
||||
format
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let errors = formats
|
||||
.iter()
|
||||
.map(|format| format.is_none().then_some(DiskError::DiskNotFound))
|
||||
.collect();
|
||||
(formats, errors)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_format_read_quorum_follows_each_sets_metadata_layout() {
|
||||
// These are system-metadata EC boundaries, independent of user-object
|
||||
// storage classes and of how many drives are hosted by each node.
|
||||
for (set_width, read_quorum) in [(1, 1), (2, 1), (3, 2), (4, 2), (6, 3), (8, 4), (12, 6), (16, 8)] {
|
||||
for set_count in [1, 2, 5] {
|
||||
let reference = FormatV3::new(set_count, set_width);
|
||||
let mut formats = (0..set_count * set_width)
|
||||
.map(|slot| {
|
||||
let set = slot / set_width;
|
||||
let drive = slot % set_width;
|
||||
// Rotate surviving slots between sets so admission
|
||||
// cannot depend on the first drive or node being up.
|
||||
((drive + set) % set_width < read_quorum).then(|| {
|
||||
let mut format = reference.clone();
|
||||
format.erasure.this = reference.erasure.sets[set][drive];
|
||||
format
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut errors = formats
|
||||
.iter()
|
||||
.map(|format| format.is_none().then_some(DiskError::DiskNotFound))
|
||||
.collect::<Vec<_>>();
|
||||
let (loaded, members) = select_existing_format_read_quorum(&formats, &errors, set_width)
|
||||
.unwrap_or_else(|| panic!("metadata read quorum must load {set_count} sets of {set_width} drives"));
|
||||
assert_eq!(loaded.shared_identity(), reference.shared_identity());
|
||||
assert!(loaded.erasure.this.is_nil());
|
||||
assert_eq!(members.iter().filter(|member| **member).count(), set_count * read_quorum);
|
||||
|
||||
// More members in other sets cannot compensate for one set
|
||||
// falling below its own metadata reconstruction boundary.
|
||||
for slot in 0..(set_count - 1) * set_width {
|
||||
let mut format = reference.clone();
|
||||
format.erasure.this = reference.erasure.sets[slot / set_width][slot % set_width];
|
||||
formats[slot] = Some(format);
|
||||
errors[slot] = None;
|
||||
}
|
||||
let lost_slot = formats
|
||||
.iter()
|
||||
.rposition(Option::is_some)
|
||||
.expect("at least one surviving drive");
|
||||
formats[lost_slot] = None;
|
||||
errors[lost_slot] = Some(DiskError::DiskNotFound);
|
||||
assert!(
|
||||
select_existing_format_read_quorum(&formats, &errors, set_width).is_none(),
|
||||
"one set below read quorum must block {set_count} sets of {set_width} drives"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_format_read_quorum_preserves_election_and_set_boundaries() {
|
||||
let (mut formats, mut errors) = half_pool_formats();
|
||||
assert!(select_existing_format_read_quorum(&[], &[], 0).is_none());
|
||||
assert!(select_existing_format_read_quorum(&formats, &errors[..23], 4).is_none());
|
||||
assert!(select_existing_format_read_quorum(&formats, &errors, 0).is_none());
|
||||
assert!(select_existing_format_read_quorum(&formats, &errors, 6).is_none());
|
||||
assert!(select_existing_format_read_quorum(&formats, &errors, 4).is_some());
|
||||
assert!(matches!(get_format_erasure_in_quorum(&formats, 0), Err(Error::ErasureReadQuorum)));
|
||||
|
||||
let mut extra = formats[0].as_ref().expect("seed format").clone();
|
||||
extra.erasure.this = extra.erasure.sets[0][2];
|
||||
formats[2] = Some(extra);
|
||||
errors[2] = None;
|
||||
assert!(get_format_erasure_in_quorum(&formats, 0).is_ok(), "13 votes must establish a majority");
|
||||
formats[5] = None;
|
||||
errors[5] = Some(DiskError::DiskNotFound);
|
||||
assert!(
|
||||
select_existing_format_read_quorum(&formats, &errors, 4).is_none(),
|
||||
"12 votes cannot compensate for a set with only one readable drive"
|
||||
);
|
||||
formats[2] = None;
|
||||
errors[2] = Some(DiskError::DiskNotFound);
|
||||
assert!(select_existing_format_read_quorum(&formats, &errors, 4).is_none(), "11 votes must wait");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_format_read_quorum_rejects_conflicts_invalid_slots_and_blank_disks() {
|
||||
for failure in [
|
||||
"conflict",
|
||||
"wrong_slot",
|
||||
"nil",
|
||||
"duplicate",
|
||||
"blank",
|
||||
"corrupt",
|
||||
"access_denied",
|
||||
"tie",
|
||||
] {
|
||||
let (mut formats, mut errors) = half_pool_formats();
|
||||
match failure {
|
||||
"conflict" => formats[1].as_mut().expect("second format").id = Uuid::new_v4(),
|
||||
"wrong_slot" => {
|
||||
let first_id = formats[0].as_ref().expect("first format").erasure.this;
|
||||
formats[1].as_mut().expect("second format").erasure.this = first_id;
|
||||
}
|
||||
"nil" => {
|
||||
for format in formats.iter_mut().flatten() {
|
||||
format.id = Uuid::nil();
|
||||
}
|
||||
}
|
||||
"duplicate" => {
|
||||
for format in formats.iter_mut().flatten() {
|
||||
format.erasure.sets[5][3] = format.erasure.sets[0][0];
|
||||
}
|
||||
}
|
||||
"blank" => errors[2] = Some(DiskError::UnformattedDisk),
|
||||
"corrupt" => errors[2] = Some(DiskError::CorruptedFormat),
|
||||
"access_denied" => errors[2] = Some(DiskError::FileAccessDenied),
|
||||
"tie" => {
|
||||
let other = FormatV3::new(6, 4);
|
||||
for slot in 0..24 {
|
||||
if formats[slot].is_none() {
|
||||
let mut format = other.clone();
|
||||
format.erasure.this = other.erasure.sets[slot / 4][slot % 4];
|
||||
formats[slot] = Some(format);
|
||||
errors[slot] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(
|
||||
select_existing_format_read_quorum(&formats, &errors, 4).is_none(),
|
||||
"{failure} must not authorize degraded startup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_format_load_accepts_each_half_of_four_by_six_without_format_writes() {
|
||||
let (_root, all_disks) = local_disks_with_set_width(24, 4).await;
|
||||
let reference = FormatV3::new(6, 4);
|
||||
for (slot, disk) in all_disks.iter().enumerate() {
|
||||
let mut format = reference.clone();
|
||||
format.erasure.this = reference.erasure.sets[slot / 4][slot % 4];
|
||||
save_format_file(disk, &Some(format)).await.expect("persist existing format");
|
||||
}
|
||||
let before = format_bytes(&all_disks).await;
|
||||
for first in 0..4 {
|
||||
for second in first + 1..4 {
|
||||
let mut disks = all_disks.clone();
|
||||
for (slot, disk) in disks.iter_mut().enumerate() {
|
||||
if slot % 4 != first && slot % 4 != second {
|
||||
*disk = None;
|
||||
}
|
||||
}
|
||||
let loaded = connect_load_init_formats_with_instance_ctx(
|
||||
&Arc::new(InstanceContext::new()),
|
||||
first == 0,
|
||||
&mut disks,
|
||||
6,
|
||||
4,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("two nodes must load the existing format");
|
||||
assert_eq!(loaded.format.shared_identity(), reference.shared_identity());
|
||||
assert!(matches!(loaded.pool_meta_bootstrap_authority, PoolMetaBootstrapAuthority::None));
|
||||
assert_eq!(disks.iter().flatten().count(), 12);
|
||||
assert_eq!(format_bytes(&all_disks).await, before, "read startup must never rewrite formats");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_format_load_succeeds_with_a_strict_majority() {
|
||||
let (_temp_dir, mut disks) = two_local_disks_with_missing_third().await;
|
||||
|
||||
@@ -448,8 +448,8 @@ const MAX_LIST_OBJECTS_METADATA_FAST_STALENESS_MS: u64 = 60_000;
|
||||
const LIST_OBJECTS_INDEX_PROVIDER_WALKER_KEY_ONLY: &str = "walker_key_only";
|
||||
const LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY: &str = "persistent_key_only";
|
||||
const LIST_OBJECTS_INDEX_PROVIDER_PERSISTENT_KEY_ONLY_DEFAULT_GENERATION: &str = "persistent-key-only";
|
||||
const PERSISTENT_KEY_ONLY_INDEX_FORMAT_VERSION: u8 = 2;
|
||||
const PERSISTENT_KEY_ONLY_INDEX_HEADER: &str = "# rustfs-listobjects-key-only-v2";
|
||||
const PERSISTENT_KEY_ONLY_INDEX_FORMAT_VERSION: u8 = 3;
|
||||
const PERSISTENT_KEY_ONLY_INDEX_HEADER: &str = "# rustfs-listobjects-key-only-v3";
|
||||
const PERSISTENT_KEY_ONLY_INDEX_BUCKET_HEADER: &str = "# bucket=";
|
||||
const PERSISTENT_KEY_ONLY_INDEX_GENERATION_HEADER: &str = "# generation=";
|
||||
const PERSISTENT_KEY_ONLY_INDEX_CHECKPOINT_HEADER: &str = "# checkpoint_high_water_mark=";
|
||||
@@ -656,7 +656,10 @@ impl PersistentListMetadataObject {
|
||||
name: object.name.clone(),
|
||||
size: object.size,
|
||||
mod_time: object.mod_time,
|
||||
etag: object.etag.clone(),
|
||||
etag: object
|
||||
.etag
|
||||
.clone()
|
||||
.filter(|_| rustfs_utils::http::etag_can_be_listed(&object.user_defined)),
|
||||
storage_class: object.storage_class.clone(),
|
||||
}
|
||||
}
|
||||
@@ -2361,24 +2364,43 @@ fn list_objects_paginate(
|
||||
(objects, prefixes, is_truncated, next_marker, next_version_idmarker)
|
||||
}
|
||||
|
||||
fn list_objects_paginate_versions<'a>(
|
||||
mut get_objects: Vec<ObjectInfo>,
|
||||
delimiter: &Option<String>,
|
||||
struct VersionedListPagination<'a, I> {
|
||||
delimiter: &'a Option<String>,
|
||||
max_keys: i32,
|
||||
disk_has_more: bool,
|
||||
cache_id: Option<&str>,
|
||||
marker: Option<&str>,
|
||||
last_scanned_key: Option<&str>,
|
||||
raw_keys: impl Iterator<Item = &'a str>,
|
||||
) -> (Vec<ObjectInfo>, Vec<String>, bool, Option<String>, Option<String>) {
|
||||
filter_versions_common_prefixes_after_marker(&mut get_objects, delimiter.as_deref(), marker);
|
||||
let prefix_at_page_boundary =
|
||||
versioned_prefix_page_boundary_key(&get_objects, raw_keys, delimiter.as_deref(), max_keys, disk_has_more);
|
||||
cache_id: Option<&'a str>,
|
||||
marker: Option<&'a str>,
|
||||
last_scanned_key: Option<&'a str>,
|
||||
raw_keys: I,
|
||||
}
|
||||
|
||||
let (objects, prefixes, is_truncated, mut next_marker, mut next_version_idmarker) =
|
||||
list_objects_paginate(get_objects, delimiter, max_keys, disk_has_more, cache_id, true, last_scanned_key);
|
||||
fn list_objects_paginate_versions<'a, I>(
|
||||
mut get_objects: Vec<ObjectInfo>,
|
||||
pagination: VersionedListPagination<'a, I>,
|
||||
) -> (Vec<ObjectInfo>, Vec<String>, bool, Option<String>, Option<String>)
|
||||
where
|
||||
I: Iterator<Item = &'a str>,
|
||||
{
|
||||
filter_versions_common_prefixes_after_marker(&mut get_objects, pagination.delimiter.as_deref(), pagination.marker);
|
||||
let prefix_at_page_boundary = versioned_prefix_page_boundary_key(
|
||||
&get_objects,
|
||||
pagination.raw_keys,
|
||||
pagination.delimiter.as_deref(),
|
||||
pagination.max_keys,
|
||||
pagination.disk_has_more,
|
||||
);
|
||||
|
||||
let (objects, prefixes, is_truncated, mut next_marker, mut next_version_idmarker) = list_objects_paginate(
|
||||
get_objects,
|
||||
pagination.delimiter,
|
||||
pagination.max_keys,
|
||||
pagination.disk_has_more,
|
||||
pagination.cache_id,
|
||||
true,
|
||||
pagination.last_scanned_key,
|
||||
);
|
||||
if is_truncated && let Some(raw_key) = prefix_at_page_boundary {
|
||||
next_marker = Some(append_list_cache_id_to_marker(raw_key, cache_id));
|
||||
next_marker = Some(append_list_cache_id_to_marker(raw_key, pagination.cache_id));
|
||||
next_version_idmarker = None;
|
||||
}
|
||||
|
||||
@@ -4326,13 +4348,15 @@ impl ECStore {
|
||||
|
||||
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate_versions(
|
||||
get_objects,
|
||||
&delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
next_cache_id.as_deref(),
|
||||
opts.marker.as_deref(),
|
||||
last_scanned_key.as_deref(),
|
||||
entries.entries().iter().map(|entry| entry.name.as_str()),
|
||||
VersionedListPagination {
|
||||
delimiter: &delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
cache_id: next_cache_id.as_deref(),
|
||||
marker: opts.marker.as_deref(),
|
||||
last_scanned_key: last_scanned_key.as_deref(),
|
||||
raw_keys: entries.entries().iter().map(|entry| entry.name.as_str()),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ListObjectVersionsInfo {
|
||||
@@ -5754,13 +5778,15 @@ impl Sets {
|
||||
|
||||
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate_versions(
|
||||
get_objects,
|
||||
&delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
next_cache_id.as_deref(),
|
||||
opts.marker.as_deref(),
|
||||
last_scanned_key.as_deref(),
|
||||
entries.entries().iter().map(|entry| entry.name.as_str()),
|
||||
VersionedListPagination {
|
||||
delimiter: &delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
cache_id: next_cache_id.as_deref(),
|
||||
marker: opts.marker.as_deref(),
|
||||
last_scanned_key: last_scanned_key.as_deref(),
|
||||
raw_keys: entries.entries().iter().map(|entry| entry.name.as_str()),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ListObjectVersionsInfo {
|
||||
@@ -6568,13 +6594,15 @@ impl SetDisks {
|
||||
|
||||
let (objects, prefixes, is_truncated, next_marker, next_version_idmarker) = list_objects_paginate_versions(
|
||||
get_objects,
|
||||
&delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
next_cache_id.as_deref(),
|
||||
opts.marker.as_deref(),
|
||||
last_scanned_key.as_deref(),
|
||||
entries.entries().iter().map(|entry| entry.name.as_str()),
|
||||
VersionedListPagination {
|
||||
delimiter: &delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
cache_id: next_cache_id.as_deref(),
|
||||
marker: opts.marker.as_deref(),
|
||||
last_scanned_key: last_scanned_key.as_deref(),
|
||||
raw_keys: entries.entries().iter().map(|entry| entry.name.as_str()),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ListObjectVersionsInfo {
|
||||
@@ -8848,13 +8876,15 @@ mod test {
|
||||
let get_objects = fold_delimiter_page(&window, "", "-");
|
||||
let (objects, prefixes, is_truncated, next_marker, _v) = super::list_objects_paginate_versions(
|
||||
get_objects,
|
||||
&delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
None,
|
||||
marker.as_deref(),
|
||||
last_scanned.as_deref(),
|
||||
window.iter().map(String::as_str),
|
||||
super::VersionedListPagination {
|
||||
delimiter: &delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
cache_id: None,
|
||||
marker: marker.as_deref(),
|
||||
last_scanned_key: last_scanned.as_deref(),
|
||||
raw_keys: window.iter().map(String::as_str),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -8927,13 +8957,15 @@ mod test {
|
||||
let get_objects = fold_delimiter_page(&window, "", "-");
|
||||
let (objects, prefixes, is_truncated, next_marker, _) = super::list_objects_paginate_versions(
|
||||
get_objects,
|
||||
&delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
None,
|
||||
marker.as_deref(),
|
||||
last_scanned.as_deref(),
|
||||
window.iter().map(String::as_str),
|
||||
super::VersionedListPagination {
|
||||
delimiter: &delimiter,
|
||||
max_keys,
|
||||
disk_has_more,
|
||||
cache_id: None,
|
||||
marker: marker.as_deref(),
|
||||
last_scanned_key: last_scanned.as_deref(),
|
||||
raw_keys: window.iter().map(String::as_str),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(objects.len() + prefixes.len(), 1, "a page must respect max-keys");
|
||||
@@ -9176,6 +9208,38 @@ mod test {
|
||||
assert_eq!(parsed, object);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_listing_snapshots_cannot_restore_legacy_encrypted_fingerprints() {
|
||||
let mut object = ObjectInfo {
|
||||
name: "encrypted".to_owned(),
|
||||
etag: Some("fbfcbcc6d2f035411e6268c8b119593e".to_owned()),
|
||||
user_defined: Arc::new(HashMap::from([(
|
||||
"x-amz-server-side-encryption-customer-algorithm".to_owned(),
|
||||
"AES256".to_owned(),
|
||||
)])),
|
||||
..Default::default()
|
||||
};
|
||||
let snapshot = PersistentListMetadataObject::from_object_info(&object);
|
||||
assert!(snapshot.to_object_info("bucket").etag.is_none());
|
||||
let mut old_snapshot = snapshot;
|
||||
old_snapshot.etag = object.etag.clone();
|
||||
let old_contents = format!(
|
||||
"# rustfs-listobjects-key-only-v2\n{}\n",
|
||||
encode_persistent_list_metadata_object(&old_snapshot)
|
||||
);
|
||||
let old_index = parse_persistent_key_only_index(&old_contents);
|
||||
assert_ne!(old_index.format_version, PERSISTENT_KEY_ONLY_INDEX_FORMAT_VERSION);
|
||||
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
Arc::make_mut(&mut object.user_defined),
|
||||
rustfs_utils::http::SUFFIX_OPAQUE_ENCRYPTED_ETAG,
|
||||
"v1".to_owned(),
|
||||
);
|
||||
assert_eq!(PersistentListMetadataObject::from_object_info(&object).etag, object.etag);
|
||||
object.user_defined = Arc::new(HashMap::new());
|
||||
assert_eq!(PersistentListMetadataObject::from_object_info(&object).etag, object.etag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_key_only_index_parses_legacy_keys_and_metadata_snapshots() {
|
||||
let object = PersistentListMetadataObject {
|
||||
|
||||
@@ -4849,7 +4849,9 @@ impl ECStore {
|
||||
opts: ObjectOptions,
|
||||
guards: impl IntoIterator<Item = rustfs_lock::NamespaceLockGuard>,
|
||||
) -> Result<ObjectInfo> {
|
||||
let guards: Vec<_> = guards.into_iter().collect();
|
||||
let guards = Arc::new(guards.into_iter().collect::<Vec<_>>());
|
||||
let mut opts = opts;
|
||||
opts.delete_mutation_owner = Some(Arc::new((opts.delete_mutation_owner.take(), guards.clone())));
|
||||
let store = Arc::clone(self);
|
||||
let bucket = bucket.to_owned();
|
||||
let object = object.to_owned();
|
||||
@@ -4861,7 +4863,7 @@ impl ECStore {
|
||||
if result.is_ok() {
|
||||
list_objects::observe_list_objects_mutation(&store, &bucket).await;
|
||||
}
|
||||
// Keep: the only use of `guards` here, it moves them into the task so the locks outlive a dropped caller.
|
||||
// Transient options also retain these guards through late disk I/O.
|
||||
drop(guards);
|
||||
result
|
||||
})
|
||||
@@ -4930,7 +4932,7 @@ impl ECStore {
|
||||
let mut opts = opts;
|
||||
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
|
||||
let result = self
|
||||
.handle_delete_object_with_journal_inner(bucket, object, opts, tier_journal_api)
|
||||
.handle_delete_object_with_journal_inner_boxed(bucket, object, opts, tier_journal_api)
|
||||
.await;
|
||||
enqueue_recorded_tier_free_versions(self, receipt_sink).await;
|
||||
result
|
||||
@@ -4938,6 +4940,16 @@ impl ECStore {
|
||||
.await
|
||||
}
|
||||
|
||||
fn handle_delete_object_with_journal_inner_boxed<'a>(
|
||||
&'a self,
|
||||
bucket: &'a str,
|
||||
object: &'a str,
|
||||
opts: ObjectOptions,
|
||||
tier_journal_api: Option<Arc<ECStore>>,
|
||||
) -> impl std::future::Future<Output = Result<ObjectInfo>> + Send + 'a {
|
||||
Box::pin(self.handle_delete_object_with_journal_inner(bucket, object, opts, tier_journal_api))
|
||||
}
|
||||
|
||||
async fn handle_delete_object_with_journal_inner(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -4955,9 +4967,9 @@ impl ECStore {
|
||||
if is_meta_bucketname(bucket) || (opts.delete_prefix && opts.bucket_lifecycle_lock_fence.is_some()) {
|
||||
None
|
||||
} else if opts.delete_prefix {
|
||||
Some(self.acquire_bucket_lifecycle_write_lock(bucket).await?)
|
||||
Some(Arc::new(self.acquire_bucket_lifecycle_write_lock(bucket).await?))
|
||||
} else {
|
||||
Some(self.acquire_bucket_lifecycle_read_lock(bucket).await?)
|
||||
Some(Arc::new(self.acquire_bucket_lifecycle_read_lock(bucket).await?))
|
||||
};
|
||||
let object = if opts.delete_prefix && !opts.delete_prefix_object {
|
||||
object.to_owned()
|
||||
@@ -4975,6 +4987,7 @@ impl ECStore {
|
||||
opts.tier_delete_journal_api = tier_journal_api.clone();
|
||||
if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
opts.delete_mutation_owner = Some(Arc::new((opts.delete_mutation_owner.take(), guard.clone())));
|
||||
}
|
||||
|
||||
if !is_meta_bucketname(bucket) {
|
||||
@@ -5227,6 +5240,40 @@ impl ECStore {
|
||||
return Ok(obj);
|
||||
}
|
||||
Err(err) => {
|
||||
if (is_err_object_not_found(&err) || is_err_version_not_found(&err))
|
||||
&& (opts.version_id.is_some() || (!opts.versioned && !opts.version_suspended))
|
||||
&& opts.http_preconditions.is_none()
|
||||
&& !opts.replication_request
|
||||
{
|
||||
let mut reclaimed = None;
|
||||
for pool in &self.pools {
|
||||
if self.is_suspended(pool.pool_idx).await || self.is_pool_rebalancing(pool.pool_idx).await {
|
||||
return Err(StorageError::SlowDown);
|
||||
}
|
||||
let pool_idx = pool.pool_idx;
|
||||
let pool = pool.clone();
|
||||
match self
|
||||
.run_external_decommission_capacity_object_delete(
|
||||
pool_idx,
|
||||
bucket,
|
||||
object,
|
||||
object,
|
||||
opts.clone(),
|
||||
|opts| async move { pool.delete_object(bucket, object, opts).await },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => reclaimed = Some(result),
|
||||
Err(missing) if is_err_object_not_found(&missing) || is_err_version_not_found(&missing) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
if let Some(mut result) = reclaimed {
|
||||
result.bucket = bucket.to_owned();
|
||||
result.name = decode_dir_object(object);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
// A folder key (`prefix/`) with no object metadata may still exist on
|
||||
// disk as an orphan empty-directory tree (issue #4189): listings show
|
||||
// it as a common prefix, but no regular delete path can remove it.
|
||||
@@ -5351,13 +5398,41 @@ impl ECStore {
|
||||
tier_journal_api: Option<Arc<ECStore>>,
|
||||
) -> (Vec<DeletedObject>, Vec<Option<Error>>, Vec<Option<DeleteAccounting>>) {
|
||||
Box::pin(async move {
|
||||
let count = objects.len();
|
||||
let mut deleted = vec![DeletedObject::default(); count];
|
||||
let mut errors: Vec<Option<Error>> = (0..count).map(|_| None).collect();
|
||||
let mut accounting = vec![None; count];
|
||||
let mut valid_objects = Vec::with_capacity(count);
|
||||
let mut valid_indices = Vec::with_capacity(count);
|
||||
// Validate the literal key before encoding it or taking locks. A
|
||||
// dot-segment key must never reach filesystem path resolution.
|
||||
for (index, object) in objects.into_iter().enumerate() {
|
||||
match check_del_obj_args(bucket, &object.object_name) {
|
||||
Ok(()) => {
|
||||
valid_indices.push(index);
|
||||
valid_objects.push(object);
|
||||
}
|
||||
Err(err) => errors[index] = Some(err),
|
||||
}
|
||||
}
|
||||
if valid_objects.is_empty() {
|
||||
return (deleted, errors, accounting);
|
||||
}
|
||||
let mut opts = opts;
|
||||
let receipt_sink = install_tier_free_version_receipt_sink(&mut opts);
|
||||
let result = self
|
||||
.handle_delete_objects_with_journal_and_accounting_inner(bucket, objects, opts, tier_journal_api)
|
||||
let (valid_deleted, valid_errors, valid_accounting) = self
|
||||
.handle_delete_objects_with_journal_and_accounting_inner(bucket, valid_objects, opts, tier_journal_api)
|
||||
.await;
|
||||
enqueue_recorded_tier_free_versions(self, receipt_sink).await;
|
||||
result
|
||||
for (index, ((object, error), receipt)) in valid_indices
|
||||
.into_iter()
|
||||
.zip(valid_deleted.into_iter().zip(valid_errors).zip(valid_accounting))
|
||||
{
|
||||
deleted[index] = object;
|
||||
errors[index] = error;
|
||||
accounting[index] = receipt;
|
||||
}
|
||||
(deleted, errors, accounting)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -5394,12 +5469,13 @@ impl ECStore {
|
||||
None
|
||||
} else {
|
||||
match self.acquire_bucket_lifecycle_read_lock(bucket).await {
|
||||
Ok(guard) => Some(guard),
|
||||
Ok(guard) => Some(Arc::new(guard)),
|
||||
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
||||
}
|
||||
};
|
||||
if let Some(guard) = _bucket_lifecycle_guard.as_ref() {
|
||||
opts.add_bucket_lifecycle_lock_guard(guard);
|
||||
opts.delete_mutation_owner = Some(Arc::new((opts.delete_mutation_owner.take(), guard.clone())));
|
||||
}
|
||||
if opts.delete_replication_config_snapshot.is_none() {
|
||||
match ReplicationObjectBridge::delete_request_config_in(&self.ctx, bucket).await {
|
||||
@@ -5465,10 +5541,11 @@ impl ECStore {
|
||||
if current_bucket_incarnation_id.is_some() {
|
||||
pause_delete_after_object_lock_snapshot(bucket).await;
|
||||
}
|
||||
let _object_lock_guards = match self.acquire_delete_objects_write_locks(bucket, &objects, &mut opts).await {
|
||||
let _object_lock_guards = Arc::new(match self.acquire_delete_objects_write_locks(bucket, &objects, &mut opts).await {
|
||||
Ok(guards) => guards,
|
||||
Err(err) => return return_batch_delete_lock_error_with_accounting(objects.as_slice(), err),
|
||||
};
|
||||
});
|
||||
opts.delete_mutation_owner = Some(Arc::new((opts.delete_mutation_owner.take(), _object_lock_guards.clone())));
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
if !_object_lock_guards.is_empty() {
|
||||
notify_delete_namespace_acquired(bucket);
|
||||
@@ -9094,6 +9171,58 @@ mod tests {
|
||||
(dirs, store)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn batch_delete_rejects_dot_segments_and_keeps_valid_result_positions() {
|
||||
let bucket = "batch-delete-key-validation";
|
||||
let (_dirs, store) = multipool_version_test_store(bucket).await;
|
||||
let options = ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
};
|
||||
let victim = store
|
||||
.put_object(bucket, "victim/object", &mut PutObjReader::from_vec(b"victim".to_vec()), &options)
|
||||
.await
|
||||
.expect("write protected-prefix control object");
|
||||
store
|
||||
.put_object(bucket, "allowed/object", &mut PutObjReader::from_vec(b"allowed".to_vec()), &options)
|
||||
.await
|
||||
.expect("write valid deletion control object");
|
||||
let keys = [
|
||||
"allowed/../victim/object",
|
||||
"allowed/object",
|
||||
"a/./b",
|
||||
"../other-bucket/object",
|
||||
];
|
||||
let objects = keys
|
||||
.iter()
|
||||
.map(|key| ObjectToDelete {
|
||||
object_name: (*key).to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let (deleted, errors) = store.handle_delete_objects(bucket, objects, options).await;
|
||||
assert_eq!(deleted.len(), keys.len());
|
||||
assert_eq!(errors.len(), keys.len());
|
||||
for index in [0, 2, 3] {
|
||||
assert!(
|
||||
matches!(&errors[index], Some(StorageError::ObjectNameInvalid(_, _))),
|
||||
"invalid key {}: {:?}",
|
||||
keys[index],
|
||||
errors[index]
|
||||
);
|
||||
assert!(!deleted[index].delete_marker);
|
||||
}
|
||||
assert!(errors[1].is_none(), "valid deletion must still succeed: {:?}", errors[1]);
|
||||
assert!(deleted[1].delete_marker);
|
||||
assert_eq!(deleted[1].object_name, "allowed/object");
|
||||
let after = store
|
||||
.get_object_info(bucket, "victim/object", &ObjectOptions::default())
|
||||
.await
|
||||
.expect("the resolved victim path must remain visible");
|
||||
assert_eq!(after.version_id, victim.version_id);
|
||||
assert_eq!(after.etag, victim.etag);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn retired_marker_converges_after_bucket_recreation_on_sixteen_disks() {
|
||||
let bucket = "retired-marker-c11";
|
||||
@@ -9161,8 +9290,31 @@ mod tests {
|
||||
.expect("read original marker");
|
||||
assert_eq!(info.delete_marker_incarnation(), Some(old_incarnation));
|
||||
}
|
||||
// Taking four actual members offline preserves their existing xl.meta.
|
||||
// All deletion/recreation work below goes through the normal store API.
|
||||
// Save each stale member's inline versions before converging the old
|
||||
// bucket's delete receipts, then reintroduce them after recreation.
|
||||
let mut stale_metadata = Vec::new();
|
||||
for disk in before.iter().skip(12).flatten() {
|
||||
for version in &historical {
|
||||
let info = disk
|
||||
.read_version(
|
||||
"",
|
||||
bucket,
|
||||
"history.bin",
|
||||
&version.to_string(),
|
||||
&crate::disk::ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("read stale inline data version");
|
||||
assert!(info.inline_data() && info.data.is_some(), "fixture payload must be preserved in xl.meta");
|
||||
}
|
||||
stale_metadata.push((
|
||||
disk.read_all(bucket, "history.bin/xl.meta").await.unwrap(),
|
||||
disk.read_all(bucket, &format!("{object}/xl.meta")).await.unwrap(),
|
||||
));
|
||||
}
|
||||
for slot in 12..16 {
|
||||
set.disks.write().await[slot] = None;
|
||||
}
|
||||
@@ -9192,6 +9344,29 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("delete exact old marker with four drives offline");
|
||||
let pending = store
|
||||
.handle_delete_bucket(bucket, &crate::storage_api_contracts::bucket::DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect_err("offline participants require retained delete receipts");
|
||||
assert!(matches!(
|
||||
pending,
|
||||
StorageError::BucketNotEmpty(_) | StorageError::BucketNotEmptyWithDetails { .. }
|
||||
));
|
||||
assert!(
|
||||
!crate::bucket::retirement::is_retired(store.clone(), bucket, old_incarnation)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
*set.disks.write().await = before.clone();
|
||||
store
|
||||
.recover_delete_transactions_at_incarnation(
|
||||
bucket,
|
||||
None,
|
||||
Some(old_incarnation),
|
||||
&rustfs_heal_contracts::heal_channel::HealOpts::default(),
|
||||
)
|
||||
.await
|
||||
.expect("converge old bucket deletes on all participants");
|
||||
store
|
||||
.handle_delete_bucket(bucket, &crate::storage_api_contracts::bucket::DeleteBucketOptions::default())
|
||||
.await
|
||||
@@ -9201,6 +9376,9 @@ mod tests {
|
||||
.await
|
||||
.expect("durable retirement")
|
||||
);
|
||||
for slot in 12..16 {
|
||||
set.disks.write().await[slot] = None;
|
||||
}
|
||||
store
|
||||
.handle_make_bucket(bucket, &create)
|
||||
.await
|
||||
@@ -9226,6 +9404,13 @@ mod tests {
|
||||
.expect("write new generation");
|
||||
new_versions.push((key, info.version_id.unwrap(), payload));
|
||||
}
|
||||
for (slot, (history, marker)) in (12..16).zip(stale_metadata) {
|
||||
for (key, bytes) in [("history.bin", history), (object, marker)] {
|
||||
let root = dirs[slot].path().join(bucket).join(key);
|
||||
tokio::fs::create_dir_all(&root).await.unwrap();
|
||||
tokio::fs::write(root.join("xl.meta"), bytes).await.unwrap();
|
||||
}
|
||||
}
|
||||
*set.disks.write().await = before.clone();
|
||||
let heal = rustfs_heal_contracts::heal_channel::HealOpts {
|
||||
remove: true,
|
||||
@@ -11397,3 +11582,160 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod missing_key_delete_quorum_tests {
|
||||
use super::*;
|
||||
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations, BucketOptions, MakeBucketOptions};
|
||||
|
||||
async fn degraded_store(bucket: &str) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create authoritative never-versioned bucket");
|
||||
store.pools[0].disk_set[0].disks.write().await[0] = None;
|
||||
let info = store
|
||||
.get_bucket_info(bucket, &BucketOptions::default())
|
||||
.await
|
||||
.expect("existing bucket must remain readable at quorum");
|
||||
assert_eq!(info.name, bucket);
|
||||
let lookup = store
|
||||
.get_object_info_for_delete(bucket, "never-existed", &ObjectOptions::default())
|
||||
.await;
|
||||
assert!(
|
||||
lookup.as_ref().is_err_and(is_err_object_not_found),
|
||||
"remaining disks must establish absence through production lookup: {lookup:?}"
|
||||
);
|
||||
(dirs, store)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_delete_missing_key_preserves_quorum_noop() {
|
||||
let (_dirs, store) = degraded_store("missing-key-quorum-single").await;
|
||||
let result = store
|
||||
.delete_object_with_tier_delete_journal("missing-key-quorum-single", "never-existed", ObjectOptions::default())
|
||||
.await;
|
||||
assert!(
|
||||
result.as_ref().is_err_and(is_err_object_not_found),
|
||||
"single missing-key delete must preserve its idempotent not-found outcome at quorum: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_delete_missing_key_preserves_quorum_noop() {
|
||||
let (_dirs, store) = degraded_store("missing-key-quorum-batch").await;
|
||||
let (_, errors, _) = store
|
||||
.delete_objects_with_tier_delete_journal_and_accounting(
|
||||
"missing-key-quorum-batch",
|
||||
vec![ObjectToDelete {
|
||||
object_name: "never-existed".to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions::default(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(errors.len(), 1, "batch DELETE must return one result per key");
|
||||
assert!(
|
||||
errors.iter().all(Option::is_none),
|
||||
"batch missing-key delete must preserve its idempotent success at quorum: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn batch_delete_timeout_retains_physical_key_guard() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
use crate::disk::{DiskAPI, ReadOptions};
|
||||
use crate::storage_api_contracts::object::ObjectIO;
|
||||
temp_env::async_with_vars([("RUSTFS_DELETE_RECOVERY_TIMEOUT_SECS", Some("30"))], async {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "review-batch-late-owner";
|
||||
let key = "object";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("fixture bucket");
|
||||
let mut reader = PutObjReader::from_vec(vec![0x51; 1024 * 1024]);
|
||||
store
|
||||
.put_object(
|
||||
bucket,
|
||||
key,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed real external object");
|
||||
let disks = store.pools[0].disk_set[0].disk_inventory().await;
|
||||
let disk = disks[0].as_ref().expect("online drive");
|
||||
let info = disk
|
||||
.read_version("", bucket, key, "", &ReadOptions::default())
|
||||
.await
|
||||
.expect("seed metadata");
|
||||
assert!(!info.inline_data());
|
||||
let crate::disk::Disk::Local(local) = disk.as_ref() else {
|
||||
panic!("local disk");
|
||||
};
|
||||
let part = local
|
||||
.get_disk()
|
||||
.get_object_path_for_io(bucket, key)
|
||||
.expect("leased key")
|
||||
.join(info.data_dir.expect("external data directory").to_string())
|
||||
.join("part.1");
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release, release_rx) = std::sync::mpsc::channel::<()>();
|
||||
let hook = hooks::install_at(hooks::Stage::Remove, &part, move || {
|
||||
let _ = entered_tx.send(());
|
||||
let _ = release_rx.recv();
|
||||
});
|
||||
let deleting = store.clone();
|
||||
let delete = tokio::spawn(async move {
|
||||
deleting
|
||||
.delete_objects_with_tier_delete_journal_and_accounting(
|
||||
bucket,
|
||||
vec![ObjectToDelete {
|
||||
object_name: key.to_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), entered_rx)
|
||||
.await
|
||||
.expect("physical cleanup entry")
|
||||
.expect("entry hook");
|
||||
assert!(part.exists(), "payload unlink has not run");
|
||||
let (_, errors, _) = tokio::time::timeout(Duration::from_secs(40), delete)
|
||||
.await
|
||||
.expect("real settlement timeout must return")
|
||||
.expect("delete task");
|
||||
assert!(errors.iter().all(Option::is_none), "certified batch response: {errors:?}");
|
||||
assert!(
|
||||
store.ctx.namespace_commits_pending(),
|
||||
"background physical operation must retain its owner"
|
||||
);
|
||||
let ns = store.handle_new_ns_lock(bucket, key).await.expect("same production key lock");
|
||||
let probe = ns.get_write_lock(Duration::from_millis(300)).await;
|
||||
let acquired_while_pending = probe.is_ok();
|
||||
drop(probe);
|
||||
drop(release);
|
||||
drop(hook);
|
||||
assert!(
|
||||
!acquired_while_pending,
|
||||
"batch released the exact-key guard while physical cleanup was still paused after its real recovery timeout"
|
||||
);
|
||||
let _released = ns
|
||||
.get_write_lock(Duration::from_secs(10))
|
||||
.await
|
||||
.expect("physical cleanup must eventually release the exact-key guard");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ use crate::disk::error::{DiskError, Result as DiskResult};
|
||||
use crate::disk::{DeleteOptions, Disk, RenameDataGuards, RenameDataResp};
|
||||
use crate::runtime::instance::{InstanceContext, NamespaceCommitGuard};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use rustfs_io_metrics::{
|
||||
PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_ADMIT, PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_LOCAL_RENAME,
|
||||
PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_LOOKUP, PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_OVERHEAD,
|
||||
PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_TOTAL,
|
||||
};
|
||||
use tracing::{debug, error};
|
||||
|
||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||
@@ -331,15 +336,38 @@ async fn rename_local_data_with_ctx(
|
||||
destination: (&str, &str),
|
||||
mut guards: RenameDataGuards,
|
||||
) -> DiskResult<RenameDataResp> {
|
||||
let handler_started = rustfs_io_metrics::put_stage_timer();
|
||||
let lookup_started = rustfs_io_metrics::put_stage_timer();
|
||||
let (disk, disk_id) = local_disk_candidate(ctx, disk_ref).await?;
|
||||
let lookup_ms = lookup_started.map(|started| started.elapsed().as_secs_f64() * 1000.0);
|
||||
if let Some(duration_ms) = lookup_ms {
|
||||
rustfs_io_metrics::record_put_object_stage_duration(PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_LOOKUP, duration_ms);
|
||||
}
|
||||
let mutates_namespace = !is_meta_bucketname(source.0) || !is_meta_bucketname(destination.0);
|
||||
let admit_started = rustfs_io_metrics::put_stage_timer();
|
||||
let owner = admit_local_disk(ctx, &disk, disk_id, mutates_namespace).await?;
|
||||
let admit_ms = admit_started.map(|started| started.elapsed().as_secs_f64() * 1000.0);
|
||||
if let Some(duration_ms) = admit_ms {
|
||||
rustfs_io_metrics::record_put_object_stage_duration(PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_ADMIT, duration_ms);
|
||||
}
|
||||
guards.namespace_owner = owner.as_ref().map(|owner| owner.clone() as Arc<dyn Send + Sync>);
|
||||
let local_rename_started = rustfs_io_metrics::put_stage_timer();
|
||||
let result = disk
|
||||
.rename_data_borrowed_with_fence_observed(source.0, source.1, fi, destination.0, destination.1, guards)
|
||||
.await
|
||||
.result;
|
||||
let local_rename_ms = local_rename_started.map(|started| started.elapsed().as_secs_f64() * 1000.0);
|
||||
if let Some(duration_ms) = local_rename_ms {
|
||||
rustfs_io_metrics::record_put_object_stage_duration(PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_LOCAL_RENAME, duration_ms);
|
||||
}
|
||||
drop(owner);
|
||||
if let Some(handler_started) = handler_started {
|
||||
let handler_ms = handler_started.elapsed().as_secs_f64() * 1000.0;
|
||||
rustfs_io_metrics::record_put_object_stage_duration(PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_TOTAL, handler_ms);
|
||||
let child_ms = lookup_ms.unwrap_or(0.0) + admit_ms.unwrap_or(0.0) + local_rename_ms.unwrap_or(0.0);
|
||||
let overhead_ms = (handler_ms - child_ms).max(0.0);
|
||||
rustfs_io_metrics::record_put_object_stage_duration(PUT_STAGE_SET_DISK_RENAME_REMOTE_HANDLER_OVERHEAD, overhead_ms);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1669,7 +1697,9 @@ mod tests {
|
||||
#[serial_test::serial]
|
||||
async fn target_ordinary_timeout_keeps_its_physical_namespace_owner() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("1"))], async {
|
||||
// Allow real fixture I/O to finish under load. Expire the operation
|
||||
// with virtual time only after its physical publication is paused.
|
||||
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("60"))], async {
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let store = super::super::tests::build_store_with_ctx(ctx.clone());
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
@@ -1703,7 +1733,7 @@ mod tests {
|
||||
.await
|
||||
.expect("bounded entry");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(std::time::Duration::from_secs(2)).await;
|
||||
tokio::time::advance(std::time::Duration::from_secs(61)).await;
|
||||
tokio::time::resume();
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut rename)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 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.
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use bytes::Bytes;
|
||||
mod storage_api;
|
||||
|
||||
use std::time::Duration;
|
||||
use storage_api::cleanup_isolation::{DeleteOptions, DiskAPI, DiskError, DiskOption, Endpoint, new_disk};
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_disk_and_periodic_gc_use_affinity_without_changing_foreground() {
|
||||
const CHILD: &str = "RUSTFS_CLEANUP_LINUX_TEST_CHILD";
|
||||
let foreground = rustix::thread::sched_getaffinity(None).unwrap();
|
||||
let cpu = (0..rustix::thread::CpuSet::MAX_CPU)
|
||||
.find(|cpu| foreground.is_set(*cpu))
|
||||
.expect("the test process must have an allowed CPU");
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
for mode in ["custom", "default", "none", "restricted"] {
|
||||
if mode == "default" && (!foreground.is_set(1) || !foreground.is_set(2)) {
|
||||
continue;
|
||||
}
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.args([
|
||||
"--exact",
|
||||
"cleanup_disk_and_periodic_gc_use_affinity_without_changing_foreground",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, mode)
|
||||
.env("RUSTFS_CLEANUP_ISOLATE_ENABLE", "true")
|
||||
.env("RUSTFS_PUT_RENAME_TAIL_CLEANUP_MAX_PENDING", "4")
|
||||
.env("RUSTFS_PUT_RENAME_TAIL_CLEANUP_DEFER_WORKERS", "1")
|
||||
.env("RUSTFS_CLEANUP_DISK_MAX_PENDING", "4")
|
||||
.env("RUSTFS_CLEANUP_DISK_WORKERS", "1")
|
||||
.env("RUSTFS_CLEANUP_GC_WORKERS", "1")
|
||||
.env("RUSTFS_CLEANUP_BLOCKING_THREADS", "1")
|
||||
.env("RUSTFS_CLEANUP_ASYNC_THREADS", "1")
|
||||
.env_remove("RUSTFS_CLEANUP_CPUS")
|
||||
.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");
|
||||
match mode {
|
||||
"custom" => {
|
||||
command.env("RUSTFS_CLEANUP_CPUS", cpu.to_string());
|
||||
}
|
||||
"none" => {
|
||||
command.env("RUSTFS_CLEANUP_CPUS", "none");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
command.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 mode = std::env::var(CHILD).unwrap();
|
||||
if mode == "restricted" {
|
||||
let mut restricted = rustix::thread::CpuSet::new();
|
||||
restricted.set(cpu);
|
||||
rustix::thread::sched_setaffinity(None, &restricted).expect("restrict initialization to one allowed CPU");
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let disk = new_disk(&Endpoint::try_from(dir.path().to_str().unwrap()).unwrap(), &DiskOption::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let bucket = "cleanup-affinity";
|
||||
disk.make_volume(bucket).await.unwrap();
|
||||
disk.write_all(bucket, "old/part.1", Bytes::from_static(b"garbage"))
|
||||
.await
|
||||
.unwrap();
|
||||
let receipt = disk
|
||||
.write_all(bucket, "old/.rustfs-old-data-cleanup-receipt.json", Bytes::from_static(b"receipt"))
|
||||
.await;
|
||||
if mode == "restricted" {
|
||||
assert!(
|
||||
receipt
|
||||
.expect_err("unavailable default CPUs must reject cleanup")
|
||||
.to_string()
|
||||
.contains("allowed CPU set")
|
||||
);
|
||||
assert_eq!(disk.read_all(bucket, "old/part.1").await.unwrap(), Bytes::from_static(b"garbage"));
|
||||
return;
|
||||
}
|
||||
receipt.unwrap();
|
||||
let expected_mask = match mode.as_str() {
|
||||
"default" => "1-2".to_owned(),
|
||||
"none" => std::fs::read_to_string("/proc/thread-self/status")
|
||||
.unwrap()
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Cpus_allowed_list:"))
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_owned(),
|
||||
_ => cpu.to_string(),
|
||||
};
|
||||
disk.write_all(bucket, "live/part.1", Bytes::from_static(b"keep"))
|
||||
.await
|
||||
.unwrap();
|
||||
disk.delete(
|
||||
bucket,
|
||||
"old",
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(disk.read_all(bucket, "old/part.1").await, Err(DiskError::FileNotFound)));
|
||||
|
||||
// One async worker and one lazily started blocking worker. Inspect the
|
||||
// real process threads, including the threads executing filesystem work.
|
||||
let mut cleanup_threads = 0;
|
||||
for entry in std::fs::read_dir("/proc/self/task").unwrap() {
|
||||
let task = entry.unwrap().path();
|
||||
let Ok(name) = std::fs::read_to_string(task.join("comm")) else {
|
||||
continue;
|
||||
};
|
||||
if name.trim() != "rustfs-cleanup" {
|
||||
continue;
|
||||
}
|
||||
cleanup_threads += 1;
|
||||
let status = std::fs::read_to_string(task.join("status")).unwrap();
|
||||
let mask = status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Cpus_allowed_list:"))
|
||||
.unwrap()
|
||||
.trim();
|
||||
assert_eq!(mask, expected_mask);
|
||||
}
|
||||
assert_eq!(cleanup_threads, 2, "async and blocking workers must both use the configured runtime");
|
||||
assert_eq!(rustix::thread::sched_getaffinity(None).unwrap(), foreground);
|
||||
|
||||
let trash = dir.path().join(".rustfs.sys/tmp/.trash");
|
||||
assert!(std::fs::read_dir(&trash).unwrap().next().is_some());
|
||||
// Advance the foreground timer that dispatches the five-minute periodic
|
||||
// scan; the dedicated runtime then performs the actual physical removal.
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(301)).await;
|
||||
tokio::time::resume();
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
while std::fs::read_dir(&trash).unwrap().next().is_some() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("periodic GC must physically remove trash");
|
||||
assert_eq!(disk.read_all(bucket, "live/part.1").await.unwrap(), Bytes::from_static(b"keep"));
|
||||
assert_eq!(rustix::thread::sched_getaffinity(None).unwrap(), foreground);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// Copyright 2026 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.
|
||||
|
||||
#![cfg(all(target_os = "linux", feature = "test-util"))]
|
||||
|
||||
mod storage_api;
|
||||
|
||||
use bytes::Bytes;
|
||||
use rustfs_filemeta::{FileInfo, ObjectPartInfo};
|
||||
use std::{path::Path, time::Duration};
|
||||
use storage_api::fd_relative::{
|
||||
DiskAPI, DiskError, DiskOption, DiskStore, Endpoint, LocalPublicationPause, LocalPublicationStage, ReadOptions, new_disk,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
const RUSTFS_META_TMP_BUCKET: &str = ".rustfs.sys/tmp";
|
||||
|
||||
async fn disk_at(path: &Path) -> DiskStore {
|
||||
new_disk(
|
||||
&Endpoint::try_from(path.to_str().expect("UTF-8 test path")).expect("endpoint"),
|
||||
&DiskOption::default(),
|
||||
)
|
||||
.await
|
||||
.expect("local disk")
|
||||
}
|
||||
|
||||
fn metadata(data: Bytes, inline: bool, directory: Uuid) -> FileInfo {
|
||||
let mut fi = FileInfo::new("object", 1, 0);
|
||||
fi.erasure.index = 1;
|
||||
fi.version_id = Some(Uuid::nil());
|
||||
fi.data_dir = Some(directory);
|
||||
fi.size = i64::try_from(data.len()).expect("test data size");
|
||||
fi.parts = vec![ObjectPartInfo {
|
||||
number: 1,
|
||||
size: data.len(),
|
||||
actual_size: fi.size,
|
||||
..Default::default()
|
||||
}];
|
||||
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||
fi.data = inline.then_some(data);
|
||||
if inline {
|
||||
fi.set_inline_data();
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
async fn put(disk: &DiskStore, staging: &str, data: Bytes, inline: bool) -> Uuid {
|
||||
let directory = Uuid::new_v4();
|
||||
if !inline {
|
||||
disk.write_all(RUSTFS_META_TMP_BUCKET, &format!("{staging}/{directory}/part.1"), data.clone())
|
||||
.await
|
||||
.expect("stage shard");
|
||||
}
|
||||
disk.rename_data(RUSTFS_META_TMP_BUCKET, staging, metadata(data, inline, directory), "bucket", "object")
|
||||
.await
|
||||
.expect("commit object");
|
||||
directory
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pinned_root_reads_and_writes_original_disk_after_path_replacement() {
|
||||
let temp = tempfile::tempdir().expect("test directory");
|
||||
let root = temp.path().join("disk");
|
||||
std::fs::create_dir(&root).expect("disk root");
|
||||
let disk = disk_at(&root).await;
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
disk.write_all("bucket", "metadata", Bytes::from_static(b"original"))
|
||||
.await
|
||||
.expect("initial write");
|
||||
let moved = temp.path().join("original");
|
||||
std::fs::rename(&root, &moved).expect("move mount path");
|
||||
std::fs::create_dir_all(root.join("bucket")).expect("replacement disk");
|
||||
std::fs::write(root.join("bucket/metadata"), b"replacement").expect("replacement sentinel");
|
||||
assert_eq!(
|
||||
disk.read_all("bucket", "metadata").await.expect("pinned read"),
|
||||
Bytes::from_static(b"original")
|
||||
);
|
||||
disk.write_all("bucket", "metadata", Bytes::from_static(b"updated"))
|
||||
.await
|
||||
.expect("pinned write");
|
||||
assert_eq!(std::fs::read(moved.join("bucket/metadata")).expect("original disk"), b"updated");
|
||||
assert_eq!(std::fs::read(root.join("bucket/metadata")).expect("replacement disk"), b"replacement");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_reads_and_writes_reject_symlink_escape() {
|
||||
let temp = tempfile::tempdir().expect("test directory");
|
||||
let disk = disk_at(temp.path()).await;
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
let outside = tempfile::tempdir().expect("outside directory");
|
||||
std::fs::write(outside.path().join("sentinel"), b"preserved").expect("sentinel");
|
||||
std::os::unix::fs::symlink(outside.path(), temp.path().join("bucket/link")).expect("directory link");
|
||||
std::os::unix::fs::symlink(outside.path().join("sentinel"), temp.path().join("bucket/file-link")).expect("file link");
|
||||
for path in ["link/sentinel", "file-link"] {
|
||||
assert!(matches!(disk.read_all("bucket", path).await, Err(DiskError::InvalidPath)));
|
||||
assert!(matches!(
|
||||
disk.write_all("bucket", path, Bytes::from_static(b"unsafe")).await,
|
||||
Err(DiskError::InvalidPath)
|
||||
));
|
||||
}
|
||||
assert_eq!(std::fs::read(outside.path().join("sentinel")).expect("outside bytes"), b"preserved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_and_non_inline_overwrites_remain_readable_after_reopen() {
|
||||
for inline in [true, false] {
|
||||
let temp = tempfile::tempdir().expect("test directory");
|
||||
let disk = disk_at(temp.path()).await;
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
put(&disk, "first", Bytes::from_static(b"old bytes"), inline).await;
|
||||
let directory = put(&disk, "second", Bytes::from_static(b"new bytes"), inline).await;
|
||||
drop(disk);
|
||||
let disk = disk_at(temp.path()).await;
|
||||
let fi = disk
|
||||
.read_version(
|
||||
"bucket",
|
||||
"bucket",
|
||||
"object",
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("committed metadata after reopen");
|
||||
assert_eq!(fi.data_dir, Some(directory));
|
||||
if inline {
|
||||
assert_eq!(fi.data, Some(Bytes::from_static(b"new bytes")));
|
||||
} else {
|
||||
assert_eq!(
|
||||
disk.read_all("bucket", &format!("object/{directory}/part.1"))
|
||||
.await
|
||||
.expect("shard bytes"),
|
||||
Bytes::from_static(b"new bytes")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_rejects_symlinked_destination_metadata() {
|
||||
for inline in [true, false] {
|
||||
let temp = tempfile::tempdir().expect("disk directory");
|
||||
let outside = tempfile::tempdir().expect("outside directory");
|
||||
let disk = disk_at(temp.path()).await;
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
put(&disk, "seed", Bytes::from_static(b"old object"), inline).await;
|
||||
let destination = temp.path().join("bucket/object/xl.meta");
|
||||
let original = std::fs::read(&destination).expect("valid existing metadata");
|
||||
let external_metadata = outside.path().join("xl.meta");
|
||||
std::fs::write(&external_metadata, &original).expect("external valid metadata");
|
||||
std::fs::remove_file(&destination).expect("replace destination metadata");
|
||||
std::os::unix::fs::symlink(&external_metadata, &destination).expect("metadata escape link");
|
||||
|
||||
let directory = Uuid::new_v4();
|
||||
let data = Bytes::from_static(b"replacement");
|
||||
if !inline {
|
||||
disk.write_all(RUSTFS_META_TMP_BUCKET, &format!("replacement/{directory}/part.1"), data.clone())
|
||||
.await
|
||||
.expect("stage non-inline data");
|
||||
}
|
||||
let result = disk
|
||||
.rename_data(
|
||||
RUSTFS_META_TMP_BUCKET,
|
||||
"replacement",
|
||||
metadata(data, inline, directory),
|
||||
"bucket",
|
||||
"object",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(DiskError::InvalidPath)),
|
||||
"symlinked metadata must be rejected: {result:?}"
|
||||
);
|
||||
assert_eq!(std::fs::read_link(&destination).expect("link must remain"), external_metadata);
|
||||
assert_eq!(std::fs::read(&external_metadata).expect("external metadata must survive"), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prepared_publication_rejects_source_or_directory_replacement() {
|
||||
for replace_directory in [false, true] {
|
||||
let temp = tempfile::tempdir().expect("test directory");
|
||||
let disk = disk_at(temp.path()).await;
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
put(&disk, "first", Bytes::from_static(b"old bytes"), true).await;
|
||||
let old = std::fs::read(temp.path().join("bucket/object/xl.meta")).expect("old metadata");
|
||||
let mut pause = LocalPublicationPause::install(&disk, "bucket", "object/xl.meta", LocalPublicationStage::PreparedRename)
|
||||
.expect("publication pause");
|
||||
let writer = disk.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
writer
|
||||
.rename_data(
|
||||
RUSTFS_META_TMP_BUCKET,
|
||||
"second",
|
||||
metadata(Bytes::from_static(b"new bytes"), true, Uuid::new_v4()),
|
||||
"bucket",
|
||||
"object",
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(30), pause.entered())
|
||||
.await
|
||||
.expect("publication reached pause")
|
||||
.expect("pause entered");
|
||||
if replace_directory {
|
||||
std::fs::rename(temp.path().join("bucket/object"), temp.path().join("detached")).expect("detach object");
|
||||
std::fs::create_dir(temp.path().join("bucket/object")).expect("replacement directory");
|
||||
std::fs::write(temp.path().join("bucket/object/xl.meta"), &old).expect("replacement metadata");
|
||||
} else {
|
||||
let src = temp.path().join(RUSTFS_META_TMP_BUCKET).join("second/xl.meta");
|
||||
std::fs::rename(&src, src.with_extension("detached")).expect("detach staged metadata");
|
||||
std::fs::write(src, b"unrelated").expect("replacement staged file");
|
||||
}
|
||||
drop(pause);
|
||||
assert!(task.await.expect("writer task").is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(temp.path().join("bucket/object/xl.meta")).expect("old metadata survives"),
|
||||
old
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cancelled_waiter_keeps_publication_serialized_until_physical_completion() {
|
||||
let temp = tempfile::tempdir().expect("test directory");
|
||||
let disk = disk_at(temp.path()).await;
|
||||
disk.make_volume("bucket").await.expect("bucket");
|
||||
put(&disk, "first", Bytes::from_static(b"old bytes"), true).await;
|
||||
let mut pause = LocalPublicationPause::install(&disk, "bucket", "object/xl.meta", LocalPublicationStage::PreparedRename)
|
||||
.expect("publication pause");
|
||||
let writer = disk.clone();
|
||||
let pending = tokio::spawn(async move { put(&writer, "shared-stage", Bytes::from_static(b"cancelled waiter"), true).await });
|
||||
tokio::time::timeout(Duration::from_secs(30), pause.entered())
|
||||
.await
|
||||
.expect("publication reached pause")
|
||||
.expect("pause entered");
|
||||
pending.abort();
|
||||
assert!(pending.await.expect_err("waiter aborted").is_cancelled());
|
||||
let writer = disk.clone();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let mut retry = tokio::spawn(async move {
|
||||
let _ = started_tx.send(());
|
||||
put(&writer, "shared-stage", Bytes::from_static(b"latest bytes"), true).await
|
||||
});
|
||||
started_rx.await.expect("retry started");
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(200), &mut retry).await.is_err(),
|
||||
"a retry must not publish while the cancelled writer still owns physical IO"
|
||||
);
|
||||
drop(pause);
|
||||
tokio::time::timeout(Duration::from_secs(30), retry)
|
||||
.await
|
||||
.expect("retry completed")
|
||||
.expect("retry task");
|
||||
let fi = disk
|
||||
.read_version(
|
||||
"bucket",
|
||||
"bucket",
|
||||
"object",
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("latest metadata");
|
||||
assert_eq!(fi.data, Some(Bytes::from_static(b"latest bytes")));
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod storage_api;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use storage_api::contract_compat::ECStore;
|
||||
use storage_api::metadata_lock::{
|
||||
BucketOperations, CompletePart, Error, MakeBucketOptions, MultipartOperations, NamespaceLocking, ObjectIO, ObjectOperations,
|
||||
ObjectOptions, PutObjReader, PutObjectCommitBarrier, PutObjectCommitPause, init_bucket_metadata_sys,
|
||||
@@ -170,6 +171,100 @@ async fn reused_metadata_lock_still_enforces_quota() {
|
||||
.expect("acquire writer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_quota_read_does_not_ignore_later_quota() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "authoritative-quota-quota-set";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
|
||||
let mut first = PutObjReader::from_vec(b"cache no quota".to_vec());
|
||||
store
|
||||
.put_object(bucket, "warm", &mut first, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("warm no-quota metadata cache");
|
||||
store
|
||||
.update_bucket_metadata_config(bucket, "quota.json", br#"{"quota":1}"#.to_vec())
|
||||
.await
|
||||
.expect("set quota after no-quota cache was observed");
|
||||
|
||||
let mut opts = quota_snapshot_options(&store, bucket).await;
|
||||
assert!(opts.set_quota_admission(0, 1));
|
||||
let mut data = PutObjReader::from_vec(b"too large".to_vec());
|
||||
let error = store
|
||||
.put_object(bucket, "object", &mut data, &opts)
|
||||
.await
|
||||
.expect_err("quota set after a no-quota cache hit must still reject growth");
|
||||
assert!(matches!(error, Error::QuotaExceeded { limit: 1, .. }), "{error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_quota_read_allows_write_after_quota_is_cleared() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "authoritative-quota-quota-cleared";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
store
|
||||
.update_bucket_metadata_config(bucket, "quota.json", br#"{"quota":1}"#.to_vec())
|
||||
.await
|
||||
.expect("set quota");
|
||||
store
|
||||
.update_bucket_metadata_config(bucket, "quota.json", Vec::new())
|
||||
.await
|
||||
.expect("clear quota");
|
||||
|
||||
let mut data = PutObjReader::from_vec(b"allowed after clear".to_vec());
|
||||
store
|
||||
.put_object(bucket, "object", &mut data, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("authoritatively cleared quota should allow writes");
|
||||
|
||||
let mut reader = store
|
||||
.get_object_reader(bucket, "object", None, Default::default(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("read object written after quota clear");
|
||||
let mut bytes = Vec::new();
|
||||
reader.stream.read_to_end(&mut bytes).await.expect("read body");
|
||||
assert_eq!(bytes, b"allowed after clear");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authoritative_quota_read_fails_closed_on_invalid_quota_json() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
|
||||
let bucket = "authoritative-quota-invalid-quota";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket");
|
||||
store
|
||||
.update_bucket_metadata_config(bucket, "quota.json", b"{not-json".to_vec())
|
||||
.await
|
||||
.expect("persist invalid quota bytes for fail-closed read");
|
||||
|
||||
let mut data = PutObjReader::from_vec(b"must fail".to_vec());
|
||||
store
|
||||
.put_object(bucket, "object", &mut data, &ObjectOptions::default())
|
||||
.await
|
||||
.expect_err("invalid quota JSON must not degrade to no-quota");
|
||||
}
|
||||
|
||||
async fn quota_snapshot_options(store: &Arc<ECStore>, bucket: &str) -> ObjectOptions {
|
||||
ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(Uuid::new_v4().to_string()),
|
||||
expected_bucket_incarnation_id: Some(store.bucket_incarnation_id(bucket).await.expect("load incarnation")),
|
||||
object_lock_config_snapshot: Some(store.object_lock_config_snapshot(bucket).await.expect("capture snapshot")),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_put_releases_shared_metadata_guard() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
|
||||
@@ -61,6 +61,17 @@ pub(crate) mod minio_generated_read {
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) mod cleanup_isolation {
|
||||
pub(crate) use super::{DiskAPI, DiskOption, Endpoint, new_disk};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DeleteOptions, DiskError};
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "test-util", target_os = "linux"))]
|
||||
pub(crate) mod fd_relative {
|
||||
pub(crate) use super::{DiskAPI, DiskOption, DiskStore, Endpoint, new_disk};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskError, LocalPublicationPause, LocalPublicationStage, ReadOptions};
|
||||
}
|
||||
|
||||
pub(crate) mod pinned_erasure_fixtures {
|
||||
pub(crate) use super::storage_contracts::{BucketOperations, HTTPRangeSpec, MakeBucketOptions, ObjectIO};
|
||||
pub(crate) use super::{ECStore, Endpoint, Erasure, Error, ObjectOptions};
|
||||
|
||||
@@ -90,6 +90,9 @@ pub enum Error {
|
||||
source: Box<Error>,
|
||||
},
|
||||
|
||||
#[error("Bucket discovery incomplete for the requested heal scope; not all buckets could be traversed")]
|
||||
BucketDiscoveryIncomplete,
|
||||
|
||||
#[error("Invalid heal type: {heal_type}")]
|
||||
InvalidHealType { heal_type: String },
|
||||
|
||||
@@ -127,7 +130,7 @@ impl Error {
|
||||
match self {
|
||||
Error::TaskCancelled | Error::TaskTimeout | Error::StaleBucketIncarnation { .. } => false,
|
||||
Error::ReplacementTargetNotReady(_) | Error::DanglingDeleteDeferred { .. } => true,
|
||||
Error::TransientSkip { .. } => true,
|
||||
Error::TransientSkip { .. } | Error::BucketDiscoveryIncomplete => true,
|
||||
// Lock failures classify by LockError's own taxonomy: only the
|
||||
// fatal variants (ResourceNotFound / PermissionDenied /
|
||||
// Configuration) are terminal - retrying cannot fix them - while
|
||||
|
||||
@@ -853,6 +853,13 @@ mod tests {
|
||||
async fn get_bucket_info(&self, _bucket: &str) -> crate::Result<Option<crate::heal::storage_api::status::BucketInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn list_buckets_for_admin_heal(
|
||||
&self,
|
||||
_selector: Option<crate::heal::storage_api::storage::DiskSetSelector>,
|
||||
) -> crate::Result<Vec<crate::heal::storage_api::status::BucketInfo>> {
|
||||
self.list_buckets().await
|
||||
}
|
||||
|
||||
async fn list_buckets(&self) -> crate::Result<Vec<crate::heal::storage_api::status::BucketInfo>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
@@ -1400,6 +1400,15 @@ impl ErasureSetHealer {
|
||||
None => self.storage.heal_bucket(bucket, &bucket_opts).await?,
|
||||
};
|
||||
task.record_result_item(result).await;
|
||||
// Keep recovery in the checkpoint's admitted incarnation and repair
|
||||
// the publication bucket before adopting a survivor's delete proof.
|
||||
if let Some(result) = self
|
||||
.storage
|
||||
.recover_delete_transactions(bucket, None, incarnation, &self.heal_opts)
|
||||
.await?
|
||||
{
|
||||
task.record_result_item(result).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. get bucket info
|
||||
@@ -2390,6 +2399,8 @@ mod resume_loop_tests {
|
||||
#[derive(Default)]
|
||||
struct FakeStorage {
|
||||
admin_disk: Mutex<Option<DiskStore>>,
|
||||
bucket_discovery_incomplete: AtomicBool,
|
||||
bucket_discovery_scopes: Mutex<Vec<Option<crate::heal::storage_api::storage::DiskSetSelector>>>,
|
||||
bucket_pages: Mutex<HashMap<String, Page>>,
|
||||
incarnations: Mutex<HashMap<String, uuid::Uuid>>,
|
||||
receipts: Mutex<HashMap<String, Option<crate::heal::outcome::HealObjectDisposition>>>,
|
||||
@@ -2469,6 +2480,17 @@ mod resume_loop_tests {
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
async fn list_buckets_for_admin_heal(
|
||||
&self,
|
||||
selector: Option<crate::heal::storage_api::storage::DiskSetSelector>,
|
||||
) -> Result<Vec<BucketInfo>> {
|
||||
self.bucket_discovery_scopes.lock().expect("discovery scopes").push(selector);
|
||||
if self.bucket_discovery_incomplete.load(Ordering::SeqCst) {
|
||||
return Err(Error::BucketDiscoveryIncomplete);
|
||||
}
|
||||
self.list_buckets().await
|
||||
}
|
||||
|
||||
async fn list_buckets(&self) -> Result<Vec<BucketInfo>> {
|
||||
Ok(self
|
||||
.bucket_pages
|
||||
|
||||
@@ -51,9 +51,10 @@ impl ErasureSetHealer {
|
||||
}
|
||||
let resolved;
|
||||
let buckets = if buckets.is_empty() {
|
||||
let (pool_idx, set_idx) = crate::heal::utils::parse_set_disk_id(set_disk_id)?;
|
||||
resolved = self
|
||||
.storage
|
||||
.list_buckets()
|
||||
.list_buckets_for_admin_heal(Some(crate::heal::storage_api::storage::DiskSetSelector::new(pool_idx, set_idx)))
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|bucket| bucket.name)
|
||||
|
||||
@@ -483,3 +483,50 @@ async fn admin_erasure_checkpoint_publication_cannot_undo_acknowledged_cancel()
|
||||
assert_eq!(outcome.coverage, HealTraversalCoverage::Partial);
|
||||
assert_eq!((outcome.counters.processed, outcome.counters.healed), (1, 1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_bucket_discovery_rejects_incomplete_checkpoint_and_freezes_complete_empty_scope() {
|
||||
let env = make_env().await;
|
||||
env.storage.bucket_discovery_incomplete.store(true, Ordering::SeqCst);
|
||||
let result = env
|
||||
.healer
|
||||
.prepare_admin_checkpoint(&env.checkpoint, &[], "pool_0_set_0")
|
||||
.await;
|
||||
assert!(matches!(result, Err(Error::BucketDiscoveryIncomplete)));
|
||||
assert!(env.checkpoint.get_checkpoint().await.admin.is_none());
|
||||
let reloaded = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||
.await
|
||||
.expect("reload checkpoint");
|
||||
assert!(reloaded.get_checkpoint().await.admin.is_none(), "incomplete scope must not be persisted");
|
||||
assert_eq!(
|
||||
env.storage.bucket_discovery_scopes.lock().expect("scopes").as_slice(),
|
||||
&[Some(crate::heal::storage_api::storage::DiskSetSelector::new(0, 0))]
|
||||
);
|
||||
|
||||
env.storage.bucket_discovery_incomplete.store(false, Ordering::SeqCst);
|
||||
assert!(
|
||||
env.healer
|
||||
.prepare_admin_checkpoint(&env.checkpoint, &[], "pool_0_set_0")
|
||||
.await
|
||||
.expect("complete empty scope")
|
||||
.is_empty()
|
||||
);
|
||||
env.storage.bucket_discovery_incomplete.store(true, Ordering::SeqCst);
|
||||
objects(&env.storage, "created-after-admission", &["object"]);
|
||||
let reloaded = CheckpointManager::load_from_disk(env.healer.disk.clone(), &env.task_id)
|
||||
.await
|
||||
.expect("reload frozen scope");
|
||||
assert!(
|
||||
env.healer
|
||||
.prepare_admin_checkpoint(&reloaded, &[], "pool_0_set_0")
|
||||
.await
|
||||
.expect("resume frozen scope")
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
env.storage.bucket_discovery_scopes.lock().expect("scopes").len(),
|
||||
2,
|
||||
"resume must not rediscover or expand scope"
|
||||
);
|
||||
assert!(env.storage.calls().is_empty());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
// limitations under the License.
|
||||
/// The automatic disk scanner: replacement discovery and unformatted-disk enqueue.
|
||||
use super::*;
|
||||
use crate::heal::DiskStore;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) fn extend_replacement_recovery_disks(
|
||||
inventory: &mut BTreeMap<String, DiskStore>,
|
||||
disks: impl IntoIterator<Item = DiskStore>,
|
||||
) {
|
||||
for disk in disks {
|
||||
// Preserve the local snapshot when later inventory repeats its endpoint.
|
||||
inventory.entry(disk.endpoint().to_string()).or_insert(disk);
|
||||
}
|
||||
}
|
||||
|
||||
impl HealManager {
|
||||
/// Start background task to auto scan local disks and enqueue erasure set heal requests
|
||||
@@ -87,7 +99,20 @@ impl HealManager {
|
||||
let local_disk_map = local_disk_map_read().await;
|
||||
local_disk_map.values().flatten().cloned().collect::<Vec<_>>()
|
||||
};
|
||||
let local_endpoints = local_disks.iter().map(|disk| disk.endpoint()).collect::<Vec<_>>();
|
||||
let mut local_endpoints = HashMap::with_capacity(local_disks.len());
|
||||
for disk in &local_disks {
|
||||
let endpoint = disk.endpoint();
|
||||
local_endpoints.entry(endpoint.to_string()).or_insert(endpoint);
|
||||
}
|
||||
// Formatting removes a target from blank-disk discovery, but its
|
||||
// unfinished intent may still live on a remote survivor.
|
||||
let mut recovery_sets = local_disks
|
||||
.iter()
|
||||
.filter_map(|disk| {
|
||||
let endpoint = disk.endpoint();
|
||||
crate::heal::utils::format_set_disk_id_from_i32(endpoint.pool_idx, endpoint.set_idx)
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
let blocked_sets = replacement_recovery_blocked_sets
|
||||
.lock()
|
||||
.expect("replacement recovery blocked set lock poisoned")
|
||||
@@ -97,6 +122,7 @@ impl HealManager {
|
||||
let mut retry_succeeded = HashSet::new();
|
||||
let mut retry_failed = HashSet::new();
|
||||
for set_disk_id in blocked_sets {
|
||||
recovery_sets.remove(&set_disk_id);
|
||||
let disks = match storage.replacement_intent_disks(&set_disk_id).await {
|
||||
Ok(disks) => disks,
|
||||
Err(error) => {
|
||||
@@ -235,11 +261,12 @@ impl HealManager {
|
||||
}
|
||||
}
|
||||
|
||||
let mut recovery_disks = local_disks.clone();
|
||||
recovery_disks.extend(retry_recovery_disks);
|
||||
for set_disk_id in endpoints.keys() {
|
||||
let mut recovery_disks = BTreeMap::new();
|
||||
extend_replacement_recovery_disks(&mut recovery_disks, local_disks.iter().cloned());
|
||||
extend_replacement_recovery_disks(&mut recovery_disks, retry_recovery_disks);
|
||||
for set_disk_id in &recovery_sets {
|
||||
match storage.replacement_intent_disks(set_disk_id).await {
|
||||
Ok(disks) => recovery_disks.extend(disks),
|
||||
Ok(disks) => extend_replacement_recovery_disks(&mut recovery_disks, disks),
|
||||
Err(error) => {
|
||||
conflicted_recovery_sets.insert(set_disk_id.clone());
|
||||
warn!(
|
||||
@@ -254,10 +281,6 @@ impl HealManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
recovery_disks.sort_by_key(|disk| disk.endpoint().to_string());
|
||||
recovery_disks
|
||||
.dedup_by(|left, right| left.endpoint().to_string() == right.endpoint().to_string());
|
||||
|
||||
// Once formatting succeeds a replacement is no longer
|
||||
// discoverable as UnformattedDisk. Re-admit exactly one
|
||||
// incomplete durable generation per set within its
|
||||
@@ -265,7 +288,7 @@ impl HealManager {
|
||||
// verified terminal cleanup. Multiple generations are a
|
||||
// durable conflict: leave every marker/state intact and
|
||||
// require reconciliation rather than choosing one.
|
||||
for disk in &recovery_disks {
|
||||
for (anchor, disk) in &recovery_disks {
|
||||
let endpoint = disk.endpoint();
|
||||
let disk_set_disk_id =
|
||||
crate::heal::utils::format_set_disk_id_from_i32(endpoint.pool_idx, endpoint.set_idx);
|
||||
@@ -351,7 +374,7 @@ impl HealManager {
|
||||
// A remote survivor may own the durable intent, but only the
|
||||
// node mounting every replacement target may advance or replay it.
|
||||
if state.replacement_targets.iter().any(|target| {
|
||||
!local_endpoints.iter().any(|endpoint| endpoint.to_string() == *target)
|
||||
!local_endpoints.contains_key(target)
|
||||
}) {
|
||||
deferred_recovery_sets.insert(state.set_disk_id.clone());
|
||||
continue;
|
||||
@@ -361,19 +384,20 @@ impl HealManager {
|
||||
}
|
||||
let mut selected = durable_recovery_candidates.remove(&task_id);
|
||||
let previous_set = selected.as_ref().map(|candidate| candidate.state.set_disk_id.clone());
|
||||
let candidate_set = state.set_disk_id.clone();
|
||||
let candidate = match ReplacementRecoveryCandidate::new(
|
||||
state.clone(),
|
||||
disk.endpoint().to_string(),
|
||||
disk.endpoint().is_local,
|
||||
state,
|
||||
anchor.clone(),
|
||||
endpoint.is_local,
|
||||
) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) => {
|
||||
conflicted_recovery_tasks.insert(task_id.clone());
|
||||
conflicted_recovery_sets.insert(state.set_disk_id.clone());
|
||||
conflicted_recovery_sets.insert(candidate_set.clone());
|
||||
if let Some(previous_set) = &previous_set {
|
||||
conflicted_recovery_sets.insert(previous_set.clone());
|
||||
}
|
||||
block_recovery_set(&state.set_disk_id);
|
||||
block_recovery_set(&candidate_set);
|
||||
if let Some(previous_set) = &previous_set {
|
||||
block_recovery_set(previous_set);
|
||||
}
|
||||
@@ -432,12 +456,7 @@ impl HealManager {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(anchor_disk) = local_disks
|
||||
.iter()
|
||||
.chain(recovery_disks.iter())
|
||||
.find(|disk| disk.endpoint().to_string() == candidate.anchor)
|
||||
.cloned()
|
||||
else {
|
||||
let Some(anchor_disk) = recovery_disks.get(&candidate.anchor).cloned() else {
|
||||
deferred_recovery_sets.insert(candidate.state.set_disk_id.clone());
|
||||
continue;
|
||||
};
|
||||
@@ -530,12 +549,7 @@ impl HealManager {
|
||||
let targets = state
|
||||
.replacement_targets
|
||||
.iter()
|
||||
.filter_map(|target| {
|
||||
local_endpoints
|
||||
.iter()
|
||||
.find(|endpoint| endpoint.to_string() == *target)
|
||||
.cloned()
|
||||
})
|
||||
.filter_map(|target| local_endpoints.get(target).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
if targets.len() != state.replacement_targets.len() {
|
||||
deferred_recovery_sets.insert(state.set_disk_id.clone());
|
||||
@@ -607,20 +621,9 @@ impl HealManager {
|
||||
}
|
||||
|
||||
for (set_disk_id, (_, targets, _, _)) in &durable_recoveries {
|
||||
let expected = targets.iter().map(ToString::to_string).collect::<HashSet<_>>();
|
||||
let observed = endpoints
|
||||
.get(set_disk_id)
|
||||
.map(|endpoints| endpoints.iter().map(ToString::to_string).collect::<HashSet<_>>())
|
||||
.unwrap_or_default();
|
||||
if !observed.is_subset(&expected) {
|
||||
replacement_recovery_blocked_sets
|
||||
.lock()
|
||||
.expect("replacement recovery blocked set lock poisoned")
|
||||
.insert(set_disk_id.clone());
|
||||
conflicted_recovery_sets.insert(set_disk_id.clone());
|
||||
continue;
|
||||
}
|
||||
endpoints.entry(set_disk_id.clone()).or_default().extend(targets.clone());
|
||||
// The durable owner must finish its exact target scope before
|
||||
// newly discovered replacements can start another generation.
|
||||
endpoints.insert(set_disk_id.clone(), targets.clone());
|
||||
}
|
||||
for set_disk_id in &conflicted_recovery_sets {
|
||||
durable_recoveries.remove(set_disk_id);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user