mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eae2e04fc7 | |||
| d001064743 | |||
| 7ea3aa2a93 | |||
| 4e48c390e7 | |||
| 02030f90a3 | |||
| d4d416ed47 | |||
| fa0a056bc1 | |||
| 1880bdca91 | |||
| 1c2f69eba9 | |||
| e193d34444 | |||
| fca714efe8 | |||
| 65bd2d575b | |||
| 58cab30351 | |||
| 5e863badc2 | |||
| b78e2ad6e7 | |||
| 605176fe75 | |||
| fea5a584c3 | |||
| e28ac2c9d4 | |||
| 4a96f06983 | |||
| 927b3969bf | |||
| fab4bb0586 | |||
| b7b26a6a49 | |||
| 197ab19f0c | |||
| a5e1e9312d | |||
| 62d148b045 | |||
| 8e6dc42772 | |||
| af10369f34 | |||
| c610d11e8f | |||
| c721416e4c | |||
| 7b29e9d65f | |||
| 85231b71e6 | |||
| ceddcdd878 | |||
| 7728eeab1a | |||
| 13dc635d51 | |||
| ead77d799f | |||
| 67db0f241f | |||
| a94f625bc8 | |||
| b7835f0f9a | |||
| 299609bb83 | |||
| 1800b90d06 | |||
| 2ccc8d8647 | |||
| 124cbfc00a | |||
| 3aa7964c58 | |||
| 42d376c6b7 | |||
| 017b85a5bf | |||
| 55023ecd86 | |||
| 1cd62c4249 |
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
|
||||
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
|
||||
| Confirmation | Complete the gate below and end the turn. |
|
||||
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
|
||||
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
|
||||
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website; reconcile main and remove the completed release branch](references/post-release-updates.md) only after complete publication. |
|
||||
|
||||
## Human Confirmation
|
||||
|
||||
@@ -37,6 +37,6 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
|
||||
|
||||
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
|
||||
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
|
||||
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
|
||||
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting unpublished candidate branches or preview tags requires an explicit decision. Completed release branches follow Phase 7 cleanup; keep release tags.
|
||||
|
||||
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
|
||||
|
||||
@@ -48,3 +48,10 @@ Use the existing announcement configuration from [website PR #112](https://githu
|
||||
3. Use the site's current package manager and required checks. For this config-only change, type-check and lint the changed file, build the site, and inspect the generated homepage for the version, visible link text, and destination. Confirm `/download` actually offers the announced version before using that link.
|
||||
4. Commit, push, and create or update the website PR when included in the authorized release scope. Include the published RustFS Release URL and checks run. Keep merge and production-deployment authority separate; reuse permission already given, but this reference grants none by itself.
|
||||
5. After an authorized merge/deployment, verify the live `https://rustfs.com/` banner and its destination. If only the PR is ready, report that state and URL; do not report the banner as deployed. A website failure does not invalidate or move the published release tag: retry this phase after resolving the failure.
|
||||
|
||||
## Reconcile main and remove the completed release branch
|
||||
|
||||
1. Compare the exact published tag with current main. Audit every release-side commit since their merge base, including inherited backports. An ahead count is a history difference, not proof of missing changes. Use ancestry and patch equivalence, then inspect current code and regression evidence for squashed, adapted, superseded, or reverted changes. Record each commit as present, superseded, release-only, or missing; a positive `git cherry` result alone does not prove a missing fix.
|
||||
2. Bring missing shared changes to main through a reviewed PR and the required checks before closing the follow-up. Preserve main's later changes; decide version bumps and pinned Console configuration separately. If a history merge is needed, use a merge commit and verify the release commit is an ancestor of main; squash does not establish that ancestry. Do not merge main into the published release branch or move its tags. Report unresolved changes instead of claiming reconciliation is complete.
|
||||
3. After publication and authorized follow-ups are complete, delete the remote `release/<target>` branch. Immediately before deletion, verify its HEAD still equals the published tag's commit and no open PR targets or originates from it. Preserve a branch with newer work or open PRs and report the blocker. Use a deletion with an expected-old-SHA check to avoid removing a concurrently advanced branch.
|
||||
4. Verify the remote branch is absent and the final and preview tags still resolve to their recorded commits. Remove a local release branch only if it is task-owned, unchecked out, and has no unpreserved work. Record cleanup separately from artifact publication; an explicitly authorized deletion before reconciliation does not make unresolved main changes complete.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-linux=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
sha256-darwin=cf31ed27e4e6bf42da5bf81f248d9d2619ac8e8615186cdacb30bd63996d7607
|
||||
sha256-linux=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
|
||||
sha256-darwin=14becc458b9eb24add510d94ee6cd04ba1fb19907d598effe1524e5242400d10
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=7893834728e2dadb98110c2aa018e6aa150c882c8a39c753e8095c3aef94cf0e
|
||||
sha256-linux=fb95ac8ba4b68aec9182fe32a59923e6b0d37b45a62ce87ea7db0dc3c40375e6
|
||||
sha256-darwin=a67a347f26be186c598b5beaf68e6bcbe1855350a35e1423dd5b8a81bcba06a5
|
||||
sha256-linux=ad4fabe811c6cca1aba89fad34ccd404b5832a6a955875c2826f6e0fa1ea4f1f
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=347a0d4e5e547d5388703062ce66a66167a845b6e57ec591ccb4c732a567c68d
|
||||
sha256-linux=4f9efa47194279185bb68249e82c788fa147abe944b6c764050e3d47f31e7926
|
||||
sha256-darwin=3814cdde8cacf4187e18188d342ed70931cc32e268e42e5bb438d565bb4d0f81
|
||||
sha256-linux=663025a80273579f95ae3f49af02c0afcbfce94df09701c360a13e607b7637db
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=4eac269f9a59b2f9be4e7a87ea6aa38454f5ebe10ede35c9b8d4218137d2cd35
|
||||
sha256=e4ee3898b840a8374cb02c64342cc15f8d7ecd1becaf4b4a2a4657290f9e7ff1
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=aa603dbbae2e0dc0803489a88887a630c542a6949466c2b0e73b8a6b5e57fcaa
|
||||
sha256=f065e7071aac9d48a7c1b301f328fa7cc14f11c4e7dc46da3e55057c4ea29bd5
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{
|
||||
"invariant": "list-uncommitted-version",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
|
||||
"name": "metacache::tests::resolve_merge_keeps_the_newest_version_at_listing_quorum"
|
||||
},
|
||||
{
|
||||
"invariant": "minio-object-fixture",
|
||||
@@ -229,7 +229,7 @@
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence"
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_listing_preserves_inputs"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
|
||||
@@ -241,11 +241,6 @@
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-enabled-index-contracts",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_enabled_index_contracts"
|
||||
},
|
||||
{
|
||||
"invariant": "borrowed-context-boundary",
|
||||
"suite": "rustfs-ecstore",
|
||||
|
||||
@@ -70,11 +70,6 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
|
||||
@echo "📦 Checking s3s footprint ratchet..."
|
||||
./scripts/check_s3s_footprint.sh
|
||||
|
||||
.PHONY: test-count-ratchet-check
|
||||
test-count-ratchet-check: ## Check no workspace crate lost tests or gained #[ignore] against the committed baseline
|
||||
@echo "🧪 Checking per-crate test-count ratchet..."
|
||||
./scripts/check_test_count_ratchet.sh
|
||||
|
||||
.PHONY: fips-wording-check
|
||||
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
|
||||
@echo "📣 Checking cryptographic capability wording guard..."
|
||||
|
||||
@@ -20,11 +20,11 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
||||
./scripts/check_no_planning_docs.sh
|
||||
|
||||
.PHONY: pre-commit
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
.PHONY: pre-pr
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
|
||||
@@ -50,9 +50,7 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_fuzz_runner.sh
|
||||
./scripts/test_python_bin.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
./scripts/check_s3s_footprint.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.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
|
||||
|
||||
+22
-21
@@ -68,6 +68,17 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
# boundaries. Reserve the run's capacity so unrelated fixtures cannot starve
|
||||
# their existing deadlines and typed-failure assertions (rustfs/backlog#2706).
|
||||
# Keep each test's internal concurrency and zero-retry policy unchanged.
|
||||
[[profile.default.overrides]]
|
||||
filter = """
|
||||
package(rustfs-ecstore) & (
|
||||
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
|
||||
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
|
||||
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
|
||||
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
|
||||
)
|
||||
"""
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||
threads-required = "num-test-threads"
|
||||
@@ -334,12 +345,6 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-ecstore) & test(=store::init::tests::dispatch_manifest_rollback_bounded_concurrency_reaches_tail_behind_slow_member)'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# These short-deadline real-disk progress probes need the test run's capacity
|
||||
# for scanner and object-lock handoffs; keep their assertions and deadlines.
|
||||
[[profile.default.overrides]]
|
||||
filter = '(package(rustfs-scanner) & (test(=scanner::tests::cycle_persist_failure::later_cycle_post_commit_failure_reloads_durable_state) | test(=scanner::tests::running_main_loop_catches_up_pause_cleared_after_startup_observe))) | (package(rustfs-ecstore) & test(=store::init::tests::restored_transitioned_delete_causally_enqueues_free_version))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -357,6 +362,17 @@ path = "junit.xml"
|
||||
|
||||
# Match the local durable-progress reservations. All members, deadlines,
|
||||
# assertions, and internal concurrency remain enabled without retries.
|
||||
[[profile.ci.overrides]]
|
||||
filter = """
|
||||
package(rustfs-ecstore) & (
|
||||
test(/^set_disk::ops::multipart::tests::complete_multipart_(quorum_success_releases_logical_guards_before_physical_tail|waits_for_tail_before_releasing_guards_and_marking_capacity)$/)
|
||||
| test(=store::heal::tests::full_format_heal_lost_after_last_save_does_not_publish_or_renew_and_latches)
|
||||
| test(/^store::init::tests::(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page|dispatch_manifest_shutdown_(drains_admitted_delete_batch_without_tail_admission|stops_aborting_validation_tail_reads|stops_authorized_tail_reads_before_receipt_or_cas))$/)
|
||||
| (binary(metadata_read_lock_reuse_test) & test(=replica_multipart_completes_while_metadata_writer_waits_for_its_snapshot))
|
||||
)
|
||||
"""
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||
threads-required = "num-test-threads"
|
||||
@@ -397,12 +413,6 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Keep short-deadline real-disk progress probes from competing with unrelated
|
||||
# tests for scanner and object-lock capacity. Assertions and deadlines stay on.
|
||||
[[profile.ci.overrides]]
|
||||
filter = '(package(rustfs-scanner) & (test(=scanner::tests::cycle_persist_failure::later_cycle_post_commit_failure_reloads_durable_state) | test(=scanner::tests::running_main_loop_catches_up_pause_cleared_after_startup_observe))) | (package(rustfs-ecstore) & test(=store::init::tests::restored_transitioned_delete_causally_enqueues_free_version))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
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'
|
||||
@@ -662,14 +672,6 @@ test-group = 'ecstore-serial-flaky'
|
||||
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
|
||||
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
|
||||
# fixture as the existing smoke modules.
|
||||
#
|
||||
# Dual-stack differential (rustfs/backlog#2740): `dual_stack_differential::`
|
||||
# starts two single-node servers from the same binary, one per
|
||||
# RUSTFS_S3_STACK value, on random ports with isolated temp dirs, and runs one
|
||||
# SDK script covering every operation of `impl S3 for FS` against both. Its
|
||||
# comparator unit tests need no server. It meets the admission criteria
|
||||
# unchanged; the module clause admits a new differential test without another
|
||||
# filter edit.
|
||||
[profile.e2e-smoke]
|
||||
default-filter = """
|
||||
package(e2e_test) & (
|
||||
@@ -678,7 +680,6 @@ default-filter = """
|
||||
| test(/^reliant::lifecycle::/)
|
||||
| test(/^conditional_delete_objects_test::/)
|
||||
| test(/^reliant::tiering::/)
|
||||
| test(/^dual_stack_differential::/)
|
||||
| test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
|
||||
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
|
||||
)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Files that may still reference s3s (`s3s::` or `s3s_sigv4`) when Phase 1 of
|
||||
# the gateway migration ends (rustfs/backlog#2734 task T0.6, rustfs/backlog#2741).
|
||||
# Consumed only by `scripts/check_s3s_footprint.sh --mode allowlist`.
|
||||
#
|
||||
# One entry per line: a repository-relative file path or a git `:(glob)`
|
||||
# pathspec. `**` matches across directories, `*` stays within one path
|
||||
# component, and a directory entry matches everything under it. Blank lines
|
||||
# and lines starting with `#` are ignored; an entry may not contain whitespace,
|
||||
# so a note belongs on its own comment line.
|
||||
#
|
||||
# Every Rust file listed by `git grep -l '\bs3s\(::\|_sigv4\)' -- '*.rs'` that
|
||||
# no entry matches fails the allowlist mode. Task T1.9 switches CI to that
|
||||
# mode; until then `--dry-run` prints the files still outside this list, which
|
||||
# is the Phase 1 tracking list.
|
||||
#
|
||||
# Never add crates/** other than crates/e2e_test: the engine crates leave s3s
|
||||
# entirely, and the script rejects such an entry. Only the s3s-facing edge of
|
||||
# the rustfs binary and the e2e oracle keep it.
|
||||
|
||||
# rustfs binary: the s3s-facing edge that survives Phase 1
|
||||
rustfs/src/storage/ecfs.rs
|
||||
rustfs/src/storage/s3_compat/**
|
||||
rustfs/src/storage/access.rs
|
||||
rustfs/src/auth.rs
|
||||
rustfs/src/error.rs
|
||||
rustfs/src/storage_api.rs
|
||||
rustfs/src/app/storage_api.rs
|
||||
rustfs/src/admin/router.rs
|
||||
rustfs/src/app/metadata_route.rs
|
||||
rustfs/src/server/http.rs
|
||||
rustfs/src/server/layer.rs
|
||||
rustfs/src/server/hybrid.rs
|
||||
rustfs/src/protocols/client.rs
|
||||
|
||||
# e2e oracle: the test target that verifies S3 behaviour against s3s
|
||||
crates/e2e_test/src/fake_s3_target/**
|
||||
crates/e2e_test/src/**
|
||||
@@ -1,17 +0,0 @@
|
||||
# Exact baseline for the direct s3s footprint, consumed only by
|
||||
# scripts/check_s3s_footprint.sh (rustfs/backlog#1677; rustfs/backlog#2734 T0.4).
|
||||
#
|
||||
# Each value is the exact measurement of the tree, not a ceiling. CI fails when a
|
||||
# measured counter differs from its line here in either direction:
|
||||
# - a larger measurement means new code widened the s3s surface: route it
|
||||
# through the gateway abstractions instead; never raise a value here;
|
||||
# - a smaller measurement means the PR shrank the surface: lower the matching
|
||||
# value to the measured number in the same PR.
|
||||
#
|
||||
# files Rust files referencing s3s paths, repo-wide minus crates/e2e_test
|
||||
# s3_error_lines lines holding s3_error!, same scope, minus those the script
|
||||
# header's rule clears (rustfs_s3_types's macro); fails closed
|
||||
# ecstore_files Rust files referencing s3s paths under crates/ecstore/src
|
||||
files=65
|
||||
s3_error_lines=289
|
||||
ecstore_files=0
|
||||
@@ -1,63 +0,0 @@
|
||||
# Per-crate test-count baseline consumed by scripts/check_test_count_ratchet.sh
|
||||
# (rustfs/backlog#2734 section 1 rule 3; T0.3 in rustfs/backlog#2738).
|
||||
#
|
||||
# One row per workspace member: <package>, tests, ignored, tab-separated.
|
||||
# tests counts #[test], #[tokio::test], #[tokio::test(...)] and #[test_case(...)]
|
||||
# attribute lines under the package directory; ignored counts #[ignore] and
|
||||
# #[ignore = "..."]. Both columns are exact: CI fails when a crate's tests fall
|
||||
# below or rise above its row, or when its ignored count changes. Raise a row in
|
||||
# the PR that adds tests; lowering one is a reviewed statement that tests were
|
||||
# intentionally removed. Regenerate with:
|
||||
# scripts/check_test_count_ratchet.sh --update
|
||||
e2e_test 956 20
|
||||
rustfs 5955 64
|
||||
rustfs-audit 70 0
|
||||
rustfs-checksums 26 0
|
||||
rustfs-common 36 0
|
||||
rustfs-concurrency 53 0
|
||||
rustfs-config 43 0
|
||||
rustfs-connect-symbol-catalog 6 0
|
||||
rustfs-credentials 24 0
|
||||
rustfs-crypto 67 0
|
||||
rustfs-data-usage 55 0
|
||||
rustfs-ecstore 7081 1
|
||||
rustfs-extension-schema 23 0
|
||||
rustfs-filemeta 364 0
|
||||
rustfs-heal 984 3
|
||||
rustfs-heal-contracts 7 0
|
||||
rustfs-iam 394 0
|
||||
rustfs-io-core 49 0
|
||||
rustfs-io-metrics 153 0
|
||||
rustfs-keystone 29 0
|
||||
rustfs-kms 715 25
|
||||
rustfs-license 2 0
|
||||
rustfs-lifecycle 155 0
|
||||
rustfs-lock 138 0
|
||||
rustfs-log-analyzer 90 0
|
||||
rustfs-madmin 210 0
|
||||
rustfs-notify 137 0
|
||||
rustfs-object-capacity 92 0
|
||||
rustfs-object-data-cache 140 0
|
||||
rustfs-obs 379 0
|
||||
rustfs-policy 725 0
|
||||
rustfs-protocols 738 1
|
||||
rustfs-protos 66 0
|
||||
rustfs-replication 209 0
|
||||
rustfs-rio 206 0
|
||||
rustfs-rio-v2 34 6
|
||||
rustfs-s3-client 69 0
|
||||
rustfs-s3-ops 8 0
|
||||
rustfs-s3-types 103 0
|
||||
rustfs-s3select-api 207 0
|
||||
rustfs-s3select-query 189 2
|
||||
rustfs-scanner 1205 20
|
||||
rustfs-scanner-metrics 87 0
|
||||
rustfs-security-governance 20 0
|
||||
rustfs-signer 31 0
|
||||
rustfs-storage-api 34 0
|
||||
rustfs-targets 603 26
|
||||
rustfs-test-utils 1 0
|
||||
rustfs-tls-runtime 43 0
|
||||
rustfs-trusted-proxies 68 0
|
||||
rustfs-utils 247 0
|
||||
rustfs-zip 30 0
|
||||
@@ -86,10 +86,6 @@ runs:
|
||||
shell: bash
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check per-crate test-count ratchet
|
||||
shell: bash
|
||||
run: ./scripts/check_test_count_ratchet.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
shell: bash
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
@@ -16,9 +16,7 @@ name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
# gateway/integration receives the daily merge of main as a direct push
|
||||
# (rustfs/backlog#2734), so its pushes must prove the result like main's.
|
||||
branches: [ main, gateway/integration ]
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
@@ -739,8 +737,6 @@ jobs:
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RUSTFS_S3_STACK: legacy
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
@@ -859,105 +855,18 @@ jobs:
|
||||
path: ${{ runner.temp }}/rustfs-e2e-*/rustfs.log
|
||||
retention-days: 3
|
||||
|
||||
# The same e2e-smoke suite against the same debug binary with the gateway
|
||||
# S3 stack selected (rustfs/backlog#2734). Report-only: it is outside the
|
||||
# Test and Lint aggregate and becomes required at T2.10. The archive and run
|
||||
# steps must stay identical to e2e-tests; scripts/ci_gate.py enforces that.
|
||||
e2e-tests-gateway:
|
||||
name: e2e-smoke (gateway, report-only until T2.10)
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
env:
|
||||
RUSTFS_S3_STACK: gateway
|
||||
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'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install awscurl
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify awscurl
|
||||
run: test -x "$AWSCURL_PATH"
|
||||
|
||||
- name: Download debug binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustfs-debug-binary
|
||||
path: target/debug
|
||||
|
||||
- name: Make binary executable
|
||||
run: chmod +x ./target/debug/rustfs
|
||||
|
||||
- name: Archive e2e smoke test binaries
|
||||
env:
|
||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-smoke-list.json
|
||||
run: |
|
||||
cargo nextest archive --profile e2e-smoke -p e2e_test --archive-file "${NEXTEST_ARCHIVE}"
|
||||
cargo nextest list --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" --message-format json > "${NEXTEST_LISTING}"
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-smoke "${NEXTEST_LISTING}"
|
||||
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run e2e smoke suite
|
||||
env:
|
||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||
SAMPLER_INTERVAL_SECS: "15"
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start e2e-smoke
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||
--status-level all --final-status-level all --failure-output final
|
||||
|
||||
- name: Upload e2e smoke diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-smoke-gateway-diagnostics-${{ github.run_number }}
|
||||
path: |
|
||||
${{ runner.temp }}/rustfs-e2e-smoke-logs/
|
||||
${{ runner.temp }}/rustfs-e2e-smoke-list.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload e2e smoke JUnit report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-smoke-gateway-junit-${{ github.run_number }}
|
||||
path: target/nextest/e2e-smoke/junit.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
e2e-full:
|
||||
name: End-to-End Tests (full merge gate)
|
||||
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
|
||||
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
||||
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
|
||||
# every PR, so it is gated to main and gateway/integration pushes, the merge
|
||||
# queue, and manual dispatch. protocols / the 7 cluster suites / replication
|
||||
# / #[ignore] are owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
||||
# every PR, so it is gated to main pushes, the merge queue, and manual
|
||||
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
|
||||
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/gateway/integration'))
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 55
|
||||
|
||||
@@ -19,9 +19,11 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/e2e-upgrade.yml"
|
||||
- "crates/e2e_test/src/common.rs"
|
||||
- "crates/e2e_test/src/distributed/**"
|
||||
- "crates/e2e_test/src/fake_s3_target/**"
|
||||
- "crates/e2e_test/src/lib.rs"
|
||||
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||
- "crates/e2e_test/src/rpc_fault_proxy.rs"
|
||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
@@ -128,6 +130,7 @@ jobs:
|
||||
run: chmod +x target/debug/rustfs
|
||||
|
||||
- name: Verify and unpack pinned previous release
|
||||
id: verify_source
|
||||
env:
|
||||
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||
run: |
|
||||
@@ -185,6 +188,28 @@ jobs:
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Run mixed-version MPU recovery
|
||||
if: ${{ !cancelled() && steps.verify_source.outcome == 'success' }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
NO_PROXY: 127.0.0.1,localhost,::1
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
ALL_PROXY: ""
|
||||
RUSTFS_E2E_LOG_FILTER: "warn,rustfs_ecstore::cluster::rpc::client=debug"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export no_proxy="$NO_PROXY"
|
||||
export RUSTFS_E2E_LOG_DIR="$RUNNER_TEMP/rustfs-upgrade-logs/mpu-mixed-recovery"
|
||||
mkdir -p "$RUSTFS_E2E_LOG_DIR"
|
||||
mpu_test='distributed::multipart_fence_acceptance_test::mixed_version_failed_complete_requires_current_coordinator_for_abort'
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
|
||||
"$mpu_test" -- --list --exact | tee "$RUSTFS_E2E_LOG_DIR/list.log"
|
||||
test "$(grep -Fxc "${mpu_test}: test" "$RUSTFS_E2E_LOG_DIR/list.log")" -eq 1
|
||||
python3 scripts/e2e_binary.py run -- cargo test --locked -p e2e_test --lib \
|
||||
"$mpu_test" -- --exact --nocapture --test-threads=1 \
|
||||
2>&1 | tee "$RUSTFS_E2E_LOG_DIR/test.log"
|
||||
|
||||
- name: Upload scanner/heal G09 evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -103,6 +103,10 @@ jobs:
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
|
||||
|
||||
- name: Test multipart parent durability barriers
|
||||
shell: pwsh
|
||||
run: cargo test -p rustfs-ecstore --lib multipart_owned_record_parent_barriers_ -- --nocapture
|
||||
|
||||
- name: Test Windows path conversion
|
||||
shell: pwsh
|
||||
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ default build (lifecycle:
|
||||
- ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore
|
||||
(bucket metadata/replication/lifecycle DTOs and error mapping). The
|
||||
count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh`
|
||||
(the `ecstore_files` line of `.config/s3s-footprint-baseline.txt`; the `object_lock` module was converted to
|
||||
(`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to
|
||||
storage-level types as the first ratchet step). Target state: the
|
||||
baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`.
|
||||
|
||||
|
||||
Generated
+181
-166
@@ -365,9 +365,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "arrow"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c25317ebddb8f2e6f006759336c95e5ded847a446f3524c27853bfe7d77a69b"
|
||||
checksum = "3f04e0a89cc175eaf3740282537851e6fd9d7276f97730b9d0ea2859c11a85df"
|
||||
dependencies = [
|
||||
"arrow-arith",
|
||||
"arrow-array",
|
||||
@@ -386,9 +386,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-arith"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab555c0ffe9cb817041c0cee80baa9663a22225d0a035693579771f2693cadc"
|
||||
checksum = "157943c720919e8f5b6731407ce5cd4c9a5ef47a75bf10edaf24b7081b5763ef"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -400,9 +400,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-array"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dae1d91737421e92c6dd7c7f9d0f2d25198fd6fc539738b628f4dad10adf533"
|
||||
checksum = "72b5b1682924f11ecf6929689c16cbc69efbb6dd141b06ad98ee1cb10d858c43"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arrow-buffer",
|
||||
@@ -419,9 +419,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-buffer"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fe9cd0064f2d7cf8ecfaf77cd70e67a980bc71aea92b63102bd37a7d3379a09"
|
||||
checksum = "b5d687afc6c927965a8a759f3b68de91933d9dfa2e1bb6e156bac84b85665e92"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"half",
|
||||
@@ -431,9 +431,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-cast"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee510bc252781d700400979a96bd1eadebeae23901132bbabd92118da1b376c5"
|
||||
checksum = "3f23c3379feede42135ef27ca60e67f6e643cd4343ae74e4c8b16c83f55afd85"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -453,9 +453,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-csv"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace83d5935421b950fea3a0a3f9972c0837c297ef98ade57127a5661b681112c"
|
||||
checksum = "85b1984628b091b8319b3c305fce21d04f9e9b2578bb733b0d0108462ae1d698"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-cast",
|
||||
@@ -468,9 +468,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-data"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54f128c73bb7fe9f7137218f910edd5b436f390e0f040b1956f5028390787897"
|
||||
checksum = "bb0a031c7ff5e2bdb443117054fb466af1d4df5b7f0ecbe197713dc8b113b1ba"
|
||||
dependencies = [
|
||||
"arrow-buffer",
|
||||
"arrow-schema",
|
||||
@@ -481,9 +481,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-ipc"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e244f39b82fa7a823e8b5984a0bd988234f9c92c1df79378fb85090e6e4c821"
|
||||
checksum = "f5dd1afadda2eb821aaf851bb4c0adef60c30cc0f1a29bd02fb99dd07fb9f58d"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -497,9 +497,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-json"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727b21ebf8894dcd9504d6ab66bffdac338391e1f85cde594526a0210299ea40"
|
||||
checksum = "e2bc4d56a0dad84cc51c5812ccff48b642047a1f3be80349cd84318983e79edb"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -522,9 +522,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-ord"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70d376b9b883239ae0f9963cdeeb59126ccaac08d60adf208a6ad3e2dd4048c3"
|
||||
checksum = "561b8209faa2cd489764b7fd07d7cb9e8d274d78048c38de59f77c41140cb834"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -535,9 +535,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-row"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48096811f92b5c3a0c59d33cfecd7d7d2ed037d81e54a27a42d232e7700e6de2"
|
||||
checksum = "0d8c68b87a569f92b90c46cb0a09d86bd9609ddc9b1bf1fc5a503c8c6907c14e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -548,9 +548,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-schema"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b6c8a6b12cf3869d299ce36ed8e1cd6fe9836264b68fb6c6538473a36124a1"
|
||||
checksum = "e87acb0c6bf6237fca7a50d7b51e801c98a18a5fe6f3bb34f7d77b85ca165388"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
@@ -558,9 +558,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-select"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e523e85fa9fb3181a391bf0edfa6a1ae0dc46df8cbf7f6b96034dcb1e2a7a04a"
|
||||
checksum = "c844141958d805b47d1208e0d12aac53fcf388ce8011639fe82aa492adde0bb4"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arrow-array",
|
||||
@@ -572,9 +572,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arrow-string"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b931a905a0e4b8832d9f54fb369cf6e545f9dc03cb3d9611216a7bd2653df60a"
|
||||
checksum = "bc130892b1a9552da77971924b390e06cf375d1f7e4344a7b7b40ca4426fa470"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -1514,6 +1514,23 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.73.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "binrw"
|
||||
version = "0.15.2"
|
||||
@@ -1726,13 +1743,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "9.0.0"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
|
||||
checksum = "6737ce8b81460bf9407909f73ff50bf13f7cb8577e1e76715f57ed0eba58ba44"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
"brotli-decompressor 6.0.1",
|
||||
"brotli-decompressor 6.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1747,9 +1764,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.0.1"
|
||||
version = "6.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
|
||||
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
@@ -1967,6 +1984,15 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
@@ -2083,6 +2109,17 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
@@ -2199,7 +2236,7 @@ version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||
dependencies = [
|
||||
"brotli 9.0.0",
|
||||
"brotli 9.1.0",
|
||||
"bzip2",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
@@ -2207,7 +2244,7 @@ dependencies = [
|
||||
"lz4",
|
||||
"memchr",
|
||||
"zstd 0.14.3",
|
||||
"zstd-safe 8.2.0",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2542,9 +2579,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.7"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc907c45bb7e9ef8fc3778defd0f3cd8c81288f1b4e748eba064948f20c47122"
|
||||
checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
@@ -2620,9 +2657,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
@@ -3632,9 +3669,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
||||
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
|
||||
dependencies = [
|
||||
"const-oid 0.10.2",
|
||||
"pem-rfc7468 1.0.0",
|
||||
@@ -4040,6 +4077,7 @@ dependencies = [
|
||||
"rustfs-utils",
|
||||
"rustls",
|
||||
"s3s",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -4066,7 +4104,7 @@ version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
||||
dependencies = [
|
||||
"der 0.8.2",
|
||||
"der 0.8.3",
|
||||
"digest 0.11.3",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
@@ -4131,9 +4169,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.14.2"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81"
|
||||
checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
@@ -5235,9 +5273,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hotpath"
|
||||
version = "0.29.1"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62f2351902fa259b758c7303505273393f6b08172ab5bdd5739a2d2bc6d554e8"
|
||||
checksum = "5b4af2b5ecf2650278fe33b0e49b68c8c484d2306475c127628316dea740cf5c"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
@@ -5270,15 +5308,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-drain"
|
||||
version = "0.29.1"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83dab2340134d9976d62f9f5172f9c6584c7f521b092f258e0e58e412e160b53"
|
||||
checksum = "a5cceb6c15fbaaeb00b51acc7be7fad533d0b56cb422b1cfc16cf9ca1624d33a"
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-macros"
|
||||
version = "0.29.1"
|
||||
version = "0.29.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a04bbe5701b097f224a08187a78b75bb9d9b4bbaff6a279c2a50ba73edbb7aac"
|
||||
checksum = "08c0a42eb299ebb7ec6b9161489e5bf132603a34ca12cecadb8490937aa0492a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6398,7 +6436,7 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"indexmap 2.14.2",
|
||||
"metrics",
|
||||
"ordered-float 5.5.0",
|
||||
"ordered-float 5.5.1",
|
||||
"quanta",
|
||||
"radix_trie",
|
||||
"rand 0.9.5",
|
||||
@@ -7332,9 +7370,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.5.0"
|
||||
version = "5.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
|
||||
checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
@@ -7451,9 +7489,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pageant"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0786409b5aaa884655c9b4065681db0835db0cc185a6fb19fb17a5d38656866"
|
||||
checksum = "e0bc922d9aa8cfcf598de27b3c6122cb2eeda9d6fe71527a22ebcb7f633540ff"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"byteorder",
|
||||
@@ -7522,9 +7560,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parquet"
|
||||
version = "59.3.1"
|
||||
version = "59.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b3d2ef0fb136e9341570f219ecb168902e76079c6d81ad555f0a4840b1e08a3"
|
||||
checksum = "8eb260d566f2fbdabe356d26aaa5d13403aac29b5b4e44463b8f9bd7b2b12680"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"arrow-array",
|
||||
@@ -7809,7 +7847,7 @@ version = "0.8.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
|
||||
dependencies = [
|
||||
"der 0.8.2",
|
||||
"der 0.8.3",
|
||||
"spki 0.8.1",
|
||||
]
|
||||
|
||||
@@ -7852,7 +7890,7 @@ dependencies = [
|
||||
"aes 0.9.3",
|
||||
"aes-gcm",
|
||||
"cbc 0.2.1",
|
||||
"der 0.8.2",
|
||||
"der 0.8.3",
|
||||
"pbkdf2 0.13.0",
|
||||
"rand_core 0.10.1",
|
||||
"scrypt 0.12.0",
|
||||
@@ -7876,7 +7914,7 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
|
||||
dependencies = [
|
||||
"der 0.8.2",
|
||||
"der 0.8.3",
|
||||
"pkcs5 0.8.1",
|
||||
"rand_core 0.10.1",
|
||||
"spki 0.8.1",
|
||||
@@ -8507,15 +8545,6 @@ version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
@@ -9147,9 +9176,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "russh"
|
||||
version = "0.64.1"
|
||||
version = "0.65.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba61e87b9ec9a39a59a6bbed4c0b8ff7fb07073405b24a767a1f2c8560ac8f58"
|
||||
checksum = "3bb77a4b259312b2abbc541bca1c13cb4e9b61c560a7fd894068c3b92e0c9dc3"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"aws-lc-rs",
|
||||
@@ -9164,7 +9193,7 @@ dependencies = [
|
||||
"curve25519-dalek 5.0.0",
|
||||
"data-encoding",
|
||||
"delegate",
|
||||
"der 0.8.2",
|
||||
"der 0.8.3",
|
||||
"digest 0.11.3",
|
||||
"ecdsa",
|
||||
"ed25519-dalek 3.0.0",
|
||||
@@ -9380,7 +9409,7 @@ dependencies = [
|
||||
"proptest",
|
||||
"prost 0.14.4",
|
||||
"pyroscope",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"quinn",
|
||||
"rand 0.10.3",
|
||||
"rcgen",
|
||||
@@ -9399,7 +9428,6 @@ dependencies = [
|
||||
"rustfs-ecstore",
|
||||
"rustfs-extension-schema",
|
||||
"rustfs-filemeta",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-heal",
|
||||
"rustfs-heal-contracts",
|
||||
"rustfs-iam",
|
||||
@@ -9678,7 +9706,6 @@ dependencies = [
|
||||
"rustfs-data-usage",
|
||||
"rustfs-erasure-codec",
|
||||
"rustfs-filemeta",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-heal-contracts",
|
||||
"rustfs-io-metrics",
|
||||
"rustfs-lifecycle",
|
||||
@@ -9784,48 +9811,6 @@ dependencies = [
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-stream"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytes",
|
||||
"http 1.5.0",
|
||||
"http-body",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-types"
|
||||
version = "0.32.0+aws.2026-10-02"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
"futures-core",
|
||||
"hex",
|
||||
"http 1.5.0",
|
||||
"md-5 0.11.0",
|
||||
"percent-encoding",
|
||||
"rustfs-gateway-stream",
|
||||
"rustfs-gateway-xml",
|
||||
"s3s",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"time",
|
||||
"twox-hash",
|
||||
"unicode-normalization",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-gateway-xml"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/rustfs/gateway.git?rev=21283e874c2381bab611e9347acbc5b92e9e2b3c#21283e874c2381bab611e9347acbc5b92e9e2b3c"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-heal"
|
||||
version = "1.0.2"
|
||||
@@ -10096,10 +10081,10 @@ dependencies = [
|
||||
"metrics-util",
|
||||
"proptest",
|
||||
"rustfs-config",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-replication",
|
||||
"rustfs-scanner-metrics",
|
||||
"rustfs-storage-api",
|
||||
"s3s",
|
||||
"serial_test",
|
||||
"temp-env",
|
||||
"time",
|
||||
@@ -10162,6 +10147,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
"rustfs-signer",
|
||||
"s3s",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
@@ -10201,7 +10187,7 @@ dependencies = [
|
||||
"jiff",
|
||||
"metrics",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"rayon",
|
||||
"rustc-hash",
|
||||
"rustfs-config",
|
||||
@@ -10382,13 +10368,10 @@ dependencies = [
|
||||
"rustfs-config",
|
||||
"rustfs-credentials",
|
||||
"rustfs-ecstore",
|
||||
"rustfs-gateway-stream",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-iam",
|
||||
"rustfs-keystone",
|
||||
"rustfs-policy",
|
||||
"rustfs-rio",
|
||||
"rustfs-s3-types",
|
||||
"rustfs-storage-api",
|
||||
"rustfs-test-utils",
|
||||
"rustfs-tls-runtime",
|
||||
@@ -10396,13 +10379,13 @@ dependencies = [
|
||||
"rustfs-trusted-proxies",
|
||||
"rustfs-utils",
|
||||
"rustls",
|
||||
"s3s",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"socket2",
|
||||
"subtle",
|
||||
"sync_wrapper",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
@@ -10452,7 +10435,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rmp",
|
||||
"rmp-serde",
|
||||
"rustfs-gateway-types",
|
||||
"s3s",
|
||||
"serde",
|
||||
"time",
|
||||
"tokio",
|
||||
@@ -10547,13 +10530,11 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"lazy_static",
|
||||
"md-5 0.11.0",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"rand 0.10.3",
|
||||
"rustfs-checksums",
|
||||
"rustfs-config",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-rio",
|
||||
"rustfs-s3-types",
|
||||
"rustfs-signer",
|
||||
"rustfs-storage-api",
|
||||
"rustfs-tls-runtime",
|
||||
@@ -10587,19 +10568,9 @@ dependencies = [
|
||||
name = "rustfs-s3-types"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"s3s",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10623,10 +10594,9 @@ dependencies = [
|
||||
"parking_lot 0.13.0",
|
||||
"rustfs-common",
|
||||
"rustfs-ecstore",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-s3-types",
|
||||
"rustfs-storage-api",
|
||||
"rustfs-test-utils",
|
||||
"s3s",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"thiserror 2.0.21",
|
||||
@@ -10652,6 +10622,7 @@ dependencies = [
|
||||
"parking_lot 0.13.0",
|
||||
"rustfs-s3select-api",
|
||||
"rustfs-test-utils",
|
||||
"s3s",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -10680,7 +10651,6 @@ dependencies = [
|
||||
"rustfs-data-usage",
|
||||
"rustfs-ecstore",
|
||||
"rustfs-filemeta",
|
||||
"rustfs-gateway-types",
|
||||
"rustfs-heal",
|
||||
"rustfs-heal-contracts",
|
||||
"rustfs-lifecycle",
|
||||
@@ -10737,10 +10707,9 @@ dependencies = [
|
||||
"bytes",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"rustfs-s3-types",
|
||||
"rustfs-utils",
|
||||
"s3s",
|
||||
"serde_urlencoded",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
@@ -10798,6 +10767,7 @@ dependencies = [
|
||||
"rustfs-utils",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"s3s",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
@@ -10914,7 +10884,7 @@ version = "1.0.2"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"blake2",
|
||||
"brotli 9.0.0",
|
||||
"brotli 9.1.0",
|
||||
"bytes",
|
||||
"convert_case 0.12.0",
|
||||
"crc-fast",
|
||||
@@ -11189,7 +11159,7 @@ dependencies = [
|
||||
"numeric_cast",
|
||||
"phf 0.14.0",
|
||||
"pin-project-lite",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml",
|
||||
"regex",
|
||||
"s3s-multipart",
|
||||
"s3s-rfc2047",
|
||||
@@ -11375,7 +11345,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"ctutils",
|
||||
"der 0.8.2",
|
||||
"der 0.8.3",
|
||||
"hybrid-array",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -11541,6 +11511,15 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -11820,9 +11799,9 @@ checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "sketches-ddsketch"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
|
||||
checksum = "45f82fa313d625dd72aaa65ec0c9f8c95407add0510c1bef3c18ea7257ec34ce"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
@@ -11951,7 +11930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der 0.8.2",
|
||||
"der 0.8.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12700,6 +12679,30 @@ dependencies = [
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
@@ -12716,9 +12719,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||
dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"toml_datetime",
|
||||
"toml_datetime 1.1.2+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12727,9 +12730,15 @@ version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
@@ -13756,6 +13765,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
@@ -14022,9 +14037,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
version = "9.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
|
||||
checksum = "ec2e3161de27091f18a831d9494e9c53378a34833af5c1dda35ad85849da18d6"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"bzip2",
|
||||
@@ -14076,10 +14091,18 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe 7.3.0",
|
||||
"apache-avro",
|
||||
"arrow-array",
|
||||
"arrow-ipc",
|
||||
"arrow-schema",
|
||||
"bytes",
|
||||
"kafka-protocol",
|
||||
"parquet",
|
||||
"semver",
|
||||
"toml",
|
||||
"zstd 0.14.3",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14088,16 +14111,7 @@ version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
|
||||
dependencies = [
|
||||
"zstd-safe 8.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14115,6 +14129,7 @@ version = "2.1.2+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
+8
-17
@@ -48,7 +48,7 @@ members = [
|
||||
"crates/replication", # Replication contracts and wire formats
|
||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||
"crates/s3-client", # S3 client for engine-side consumption of remote S3 endpoints (tiering, transition targets)
|
||||
"crates/s3-types", # S3 event and error type definitions
|
||||
"crates/s3-types", # S3 event type definitions
|
||||
"crates/s3-ops", # S3 operation definitions and mapping
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
@@ -66,6 +66,7 @@ members = [
|
||||
"crates/io-metrics", # I/O metrics collection for performance analysis
|
||||
"crates/io-core", # Buffered Bytes, mmap-then-copy, and aligned pread I/O helpers
|
||||
"crates/zip", # ZIP file handling and compression
|
||||
"crates/zstd-compat", # Fixed codec for dependencies requiring zstd 0.13
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
@@ -142,13 +143,6 @@ 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" }
|
||||
|
||||
# The gateway's S3 types: the DTOs and persisted bucket-configuration codecs the
|
||||
# engine crates move onto from s3s (rustfs/backlog#2745). The one pin of the
|
||||
# gateway in this workspace; no crate may enable a `compat-s3s*` feature except
|
||||
# the s3s-facing edge of the rustfs binary.
|
||||
rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.32.0", default-features = false }
|
||||
rustfs-gateway-stream = { git = "https://github.com/rustfs/gateway.git", rev = "21283e874c2381bab611e9347acbc5b92e9e2b3c", version = "0.2.0", default-features = false }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
@@ -267,7 +261,7 @@ aws-smithy-http-client = { default-features = false, version = "1.5.1" }
|
||||
aws-smithy-runtime-api = { version = "1.19.1" }
|
||||
aws-smithy-types = { version = "1.9.0" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "9.0.0"
|
||||
brotli = "9.1.0"
|
||||
clap = { version = "4.6.7" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.12.0"
|
||||
@@ -309,6 +303,7 @@ num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.13.0"
|
||||
object = { version = "=0.40.0", default-features = false }
|
||||
parking_lot = "0.13.0"
|
||||
same-file = "1.0.6"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||
@@ -331,10 +326,6 @@ 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" }
|
||||
# The same s3s pin under the name the legacy edge of the gateway migration uses
|
||||
# (crates/s3-types, feature `compat-s3s`). Keep `rev` identical to `s3s` above.
|
||||
# DELETE BY T4.2 (rustfs/backlog#2784).
|
||||
legacy-s3s = { package = "s3s", git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.4"
|
||||
@@ -343,7 +334,6 @@ compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.4.0" }
|
||||
strum = { version = "0.28.0" }
|
||||
sync_wrapper = "1.0.2"
|
||||
sysinfo = "0.39.6"
|
||||
temp-env = "0.3.6"
|
||||
tempfile = "3.27.0"
|
||||
@@ -366,7 +356,7 @@ winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "9.1.0"
|
||||
zip = "9.1.2"
|
||||
zstd = "0.14.3"
|
||||
|
||||
# Observability and Metrics
|
||||
@@ -387,7 +377,7 @@ libunftp = { version = "0.23.1" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.2.1" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.64.1" }
|
||||
russh = { version = "0.65.0" }
|
||||
russh-sftp = "3.0.1"
|
||||
|
||||
# WebDAV
|
||||
@@ -396,7 +386,7 @@ dav-server = "0.11.0"
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.7.2" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.29.1", default-features = false }
|
||||
hotpath = { version = "0.29.2", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.49" }
|
||||
|
||||
@@ -436,6 +426,7 @@ strip = "none"
|
||||
|
||||
# Pin upstream fixes until compatible crates.io releases are available.
|
||||
[patch.crates-io]
|
||||
zstd = { path = "crates/zstd-compat" }
|
||||
# Preserve RustFS OIDC flows while migrating the old elliptic-curve dependency chain.
|
||||
# Upstream: openidconnect-rs#244 and oauth2-rs#352.
|
||||
openidconnect = { git = "https://github.com/ramosbugs/openidconnect-rs", rev = "2f77ee73bc4554e1cb6279adb386005429fb4e41" }
|
||||
|
||||
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# Build specific version
|
||||
./docker-buildx.sh --release 1.0.3 --push
|
||||
./docker-buildx.sh --release 1.0.4 --push
|
||||
|
||||
# Build for custom registry
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
|
||||
```bash
|
||||
make docker-buildx # Build locally
|
||||
make docker-buildx-push # Build and push
|
||||
make docker-buildx-version VERSION=1.0.3 # Build specific version
|
||||
make docker-buildx-version VERSION=1.0.4 # Build specific version
|
||||
make help-docker # Show all Docker-related commands
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.4
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# 构建指定版本
|
||||
./docker-buildx.sh --release 1.0.3 --push
|
||||
./docker-buildx.sh --release 1.0.4 --push
|
||||
|
||||
# 构建并推送到自定义仓库
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
```bash
|
||||
make docker-buildx # 本地构建
|
||||
make docker-buildx-push # 构建并推送
|
||||
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
|
||||
make docker-buildx-version VERSION=1.0.4 # 构建指定版本
|
||||
make help-docker # 显示所有 Docker 相关命令
|
||||
```
|
||||
|
||||
|
||||
@@ -49,12 +49,28 @@ impl Workers {
|
||||
|
||||
/// Acquire a worker slot, waiting until one becomes available.
|
||||
pub async fn take(&self) {
|
||||
struct AcquireCancelGuard<'a> {
|
||||
workers: &'a Workers,
|
||||
completed: bool,
|
||||
}
|
||||
impl Drop for AcquireCancelGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed {
|
||||
self.workers.in_use.send_modify(|_| {});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut guard = AcquireCancelGuard {
|
||||
workers: self,
|
||||
completed: false,
|
||||
};
|
||||
let permit = match self.semaphore.acquire().await {
|
||||
Ok(permit) => permit,
|
||||
// The semaphore is owned by `self` and never closed.
|
||||
Err(_) => unreachable!("worker semaphore is never closed"),
|
||||
};
|
||||
permit.forget(); // returned manually via give()
|
||||
guard.completed = true;
|
||||
self.in_use.send_modify(|held| *held += 1);
|
||||
trace!(
|
||||
event = "worker_slot.acquire",
|
||||
@@ -82,6 +98,7 @@ impl Workers {
|
||||
});
|
||||
if released {
|
||||
self.semaphore.add_permits(1);
|
||||
self.in_use.send_modify(|_| {});
|
||||
}
|
||||
trace!(
|
||||
event = "worker_slot.release",
|
||||
@@ -99,7 +116,9 @@ impl Workers {
|
||||
pub async fn wait(&self) {
|
||||
let mut rx = self.in_use.subscribe();
|
||||
// The sender is owned by `self`, so it outlives this borrow.
|
||||
let _ = rx.wait_for(|&held| held == 0).await;
|
||||
let _ = rx
|
||||
.wait_for(|&held| held == 0 && self.semaphore.available_permits() == self.limit)
|
||||
.await;
|
||||
debug!(
|
||||
event = "worker_slot.wait",
|
||||
component = "concurrency",
|
||||
@@ -114,7 +133,9 @@ impl Workers {
|
||||
|
||||
/// Return the current number of available worker slots.
|
||||
pub async fn available(&self) -> usize {
|
||||
self.limit.saturating_sub(*self.in_use.borrow())
|
||||
self.limit
|
||||
.saturating_sub(*self.in_use.borrow())
|
||||
.min(self.semaphore.available_permits())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,4 +238,36 @@ mod tests {
|
||||
}
|
||||
assert_eq!(workers.available().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_does_not_complete_while_permit_is_in_transit_to_queued_taker() {
|
||||
use std::future::Future;
|
||||
use std::pin::pin;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
let workers = Workers::new(1).unwrap();
|
||||
workers.take().await;
|
||||
|
||||
let mut taker = pin!(workers.take());
|
||||
let mut cx = Context::from_waker(Waker::noop());
|
||||
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
let mut waiter = pin!(workers.wait());
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
// Hand off the slot to the queued taker before `taker` is polled again.
|
||||
workers.give().await;
|
||||
assert_eq!(workers.available().await, 0);
|
||||
assert!(
|
||||
matches!(waiter.as_mut().poll(&mut cx), Poll::Pending),
|
||||
"wait() must not complete while a permit is in transit to a woken taker"
|
||||
);
|
||||
|
||||
assert!(matches!(taker.as_mut().poll(&mut cx), Poll::Ready(())));
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Pending));
|
||||
|
||||
workers.give().await;
|
||||
assert!(matches!(waiter.as_mut().poll(&mut cx), Poll::Ready(())));
|
||||
assert_eq!(workers.available().await, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,10 @@ pub const ENV_HEAL_MRF_REPLAY_BATCH: &str = "RUSTFS_HEAL_MRF_REPLAY_BATCH";
|
||||
/// Default behavior keeps the MRF intent pipeline enabled.
|
||||
pub const DEFAULT_HEAL_MRF_ENABLE: bool = true;
|
||||
|
||||
/// Opt in to lifecycle V2 transitions after qualifying every reader and the rollback binary.
|
||||
pub const ENV_MRF_LIFECYCLE_V2_ENABLE: &str = "RUSTFS_MRF_LIFECYCLE_V2_ENABLE";
|
||||
pub const DEFAULT_MRF_LIFECYCLE_V2_ENABLE: bool = false;
|
||||
|
||||
/// Default MRF queue capacity (matches MinIO's 100k MRF list ceiling).
|
||||
pub const DEFAULT_HEAL_MRF_QUEUE_SIZE: usize = 100_000;
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ zip.workspace = true
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
|
||||
[dev-dependencies]
|
||||
same-file.workspace = true
|
||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
|
||||
rustfs-heal.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
//! 3. Starting with the default `rustfsadmin` credentials emits the
|
||||
//! default-credentials startup warning (observable operator signal).
|
||||
|
||||
// Keep inventory acceptance in the admin-auth PR smoke selection.
|
||||
#[cfg(test)]
|
||||
mod target_inventory_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// 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.
|
||||
|
||||
//! Exercise the target inventory through HTTP authentication and authorization.
|
||||
//! Handler-level tests separately control reconciliation locks and config faults.
|
||||
|
||||
use crate::common::{
|
||||
RustFSTestEnvironment, admin_create_user, admin_request, admin_request_with_session_token, build_test_sts_client,
|
||||
init_logging, local_http_client,
|
||||
};
|
||||
use http::header::{AUTHORIZATION, HOST};
|
||||
use reqwest::StatusCode;
|
||||
use rustfs_signer::{constants::UNSIGNED_PAYLOAD, sign_v4};
|
||||
use s3s::Body;
|
||||
use serde_json::Value;
|
||||
use std::{error::Error, time::Duration};
|
||||
|
||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||
|
||||
const SECRET: &str = "inventory-acceptance-token";
|
||||
const LIMITED_KEY: &str = "inventory-limited";
|
||||
const LIMITED_SECRET: &str = "inventory-limited-secret";
|
||||
const PATHS: [&str; 5] = [
|
||||
"/rustfs/admin/v3/target/list",
|
||||
"/rustfs/admin/v3/audit/target/list",
|
||||
"/rustfs/admin/v4/plugins/instances",
|
||||
"/rustfs/admin/v4/plugins/instances/builtin:webhook:notify:acceptance",
|
||||
"/rustfs/admin/v4/extensions/instances",
|
||||
];
|
||||
|
||||
async fn duplicate_authorization(
|
||||
env: &RustFSTestEnvironment,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let url = format!("{}{path}", env.url);
|
||||
let uri: http::Uri = url.parse()?;
|
||||
let authority = uri.authority().ok_or("inventory URL missing authority")?.to_string();
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::GET)
|
||||
.uri(uri)
|
||||
.header(HOST, authority)
|
||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||
.body(Body::empty())?;
|
||||
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||
let mut headers = signed.headers().clone();
|
||||
let authorization = headers.get(AUTHORIZATION).ok_or("signer omitted authorization")?.clone();
|
||||
headers.append(AUTHORIZATION, authorization);
|
||||
assert_eq!(headers.get_all(AUTHORIZATION).iter().count(), 2);
|
||||
Ok(local_http_client().get(url).headers(headers).send().await?)
|
||||
}
|
||||
|
||||
fn assert_unloaded_target(entry: &Value, path: &str) {
|
||||
assert_eq!(entry["service"], "webhook", "{path}: unexpected target service");
|
||||
assert!(
|
||||
entry["account_id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance")),
|
||||
"{path}: fixture target is missing"
|
||||
);
|
||||
assert_eq!(entry["status"], "offline", "{path}: unloaded targets must remain visible as offline");
|
||||
if !path.contains("/extensions/") {
|
||||
assert_eq!(entry["health_reason"], "not_loaded_in_runtime", "{path}: unexpected health reason");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn target_inventory_preserves_unloaded_targets_and_http_auth_boundaries() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
// Enabled destinations remain configured while both delivery modules are
|
||||
// disabled, so inventory reads must not require a loaded target runtime.
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_NOTIFY_ENABLE", "false"),
|
||||
("RUSTFS_AUDIT_ENABLE", "false"),
|
||||
("RUSTFS_NOTIFY_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
|
||||
("RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
|
||||
("RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENABLE_ACCEPTANCE", "on"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_ACCEPTANCE", "http://127.0.0.1:9/hook"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_AUTH_TOKEN_ACCEPTANCE", SECRET),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
admin_create_user(&env, LIMITED_KEY, LIMITED_SECRET).await?;
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(60), async {
|
||||
let assumed = build_test_sts_client(&env.url, &env.access_key, &env.secret_key, None, "e2e-target-inventory")
|
||||
.assume_role()
|
||||
.role_arn("arn:aws:iam::123456789012:role/target-inventory")
|
||||
.role_session_name("target-inventory-console-session")
|
||||
.send()
|
||||
.await?;
|
||||
let temporary = assumed
|
||||
.credentials()
|
||||
.ok_or("AssumeRole should return temporary inventory credentials")?;
|
||||
assert_ne!(
|
||||
temporary.access_key_id(),
|
||||
env.access_key,
|
||||
"inventory must exercise a distinct session credential"
|
||||
);
|
||||
assert!(
|
||||
!temporary.session_token().is_empty(),
|
||||
"temporary inventory credentials must include a token"
|
||||
);
|
||||
|
||||
for path in PATHS {
|
||||
for (principal, access_key, secret_key, session_token) in [
|
||||
("root", env.access_key.as_str(), env.secret_key.as_str(), None),
|
||||
(
|
||||
"temporary admin",
|
||||
temporary.access_key_id(),
|
||||
temporary.secret_access_key(),
|
||||
Some(temporary.session_token()),
|
||||
),
|
||||
] {
|
||||
let (status, body) = admin_request_with_session_token(
|
||||
&env.url,
|
||||
http::Method::GET,
|
||||
path,
|
||||
None,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(status, StatusCode::OK, "{path}: signed {principal} inventory failed");
|
||||
assert!(!body.contains(SECRET), "{path}: inventory exposed a target secret");
|
||||
let value: Value = serde_json::from_str(&body)?;
|
||||
let entries: Vec<&Value> = if path.ends_with("/target/list") {
|
||||
let field = if path.contains("/audit/") {
|
||||
"audit_endpoints"
|
||||
} else {
|
||||
"notification_endpoints"
|
||||
};
|
||||
value[field].as_array().ok_or("missing endpoint inventory")?.iter().collect()
|
||||
} else if path.ends_with("/instances") {
|
||||
value["instances"]
|
||||
.as_array()
|
||||
.ok_or("missing instance inventory")?
|
||||
.iter()
|
||||
.collect()
|
||||
} else {
|
||||
vec![&value]
|
||||
};
|
||||
let targets: Vec<_> = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry["account_id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.eq_ignore_ascii_case("acceptance"))
|
||||
})
|
||||
.collect();
|
||||
let expected = if path.ends_with("/instances") { 2 } else { 1 };
|
||||
assert_eq!(targets.len(), expected, "{path}: configured targets disappeared");
|
||||
for entry in &targets {
|
||||
assert_unloaded_target(entry, path);
|
||||
if path.contains("/v4/") {
|
||||
assert_eq!(entry["config"]["auth_token"], "***redacted***", "{path}: missing secret redaction");
|
||||
}
|
||||
}
|
||||
if expected == 2 {
|
||||
for domain in ["notify", "audit"] {
|
||||
assert!(targets.iter().any(|entry| entry["domain"] == domain), "{path}: missing {domain} domain");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unsigned = local_http_client().get(format!("{}{path}", env.url)).send().await?;
|
||||
assert_eq!(unsigned.status(), StatusCode::FORBIDDEN, "{path}: unsigned request was not denied");
|
||||
let body = unsigned.text().await?;
|
||||
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
|
||||
|
||||
let ambiguous = duplicate_authorization(&env, path).await?;
|
||||
assert_eq!(
|
||||
ambiguous.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"{path}: duplicate authorization was not rejected"
|
||||
);
|
||||
let body = ambiguous.text().await?;
|
||||
assert!(body.contains("<Code>AccessDenied</Code>") && body.contains("Signature is required"));
|
||||
|
||||
let (status, body) = admin_request(&env.url, http::Method::GET, path, None, LIMITED_KEY, LIMITED_SECRET).await?;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{path}: principal without admin policy was not denied");
|
||||
assert!(body.contains("<Code>AccessDenied</Code>"), "{path}: unexpected authorization error");
|
||||
assert!(!body.contains("Signature is required") && !body.contains("SignatureDoesNotMatch"));
|
||||
}
|
||||
Ok::<(), Box<dyn Error + Send + Sync>>(())
|
||||
})
|
||||
.await??;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
@@ -17,8 +17,7 @@
|
||||
//! when PublicAccessBlock configuration is missing or explicitly set.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use crate::sts_query_compat_test::{OPA_AUTH_TOKEN, OpaMock};
|
||||
use aws_sdk_s3::types::{PublicAccessBlockConfiguration, Tag, Tagging};
|
||||
use aws_sdk_s3::types::PublicAccessBlockConfiguration;
|
||||
use tracing::info;
|
||||
|
||||
async fn setup_public_bucket(
|
||||
@@ -70,98 +69,6 @@ async fn anonymous_get_object(
|
||||
local_http_client().get(&url).send().await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_plain_http_matches_secure_transport_false_bucket_policy() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
assert!(env.url.starts_with("http://"), "fixture must exercise plaintext HTTP: {}", env.url);
|
||||
|
||||
let bucket = "secure-transport-policy";
|
||||
let key = "plain-http.txt";
|
||||
let admin = env.create_s3_client();
|
||||
admin.create_bucket().bucket(bucket).send().await?;
|
||||
admin
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"plain http"))
|
||||
.send()
|
||||
.await?;
|
||||
admin
|
||||
.put_bucket_policy()
|
||||
.bucket(bucket)
|
||||
.policy(
|
||||
serde_json::json!({
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "s3:GetObject",
|
||||
"Resource": format!("arn:aws:s3:::{bucket}/*"),
|
||||
"Condition": {"Bool": {"aws:SecureTransport": "false"}}
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let response = anonymous_get_object(&env, bucket, key).await?;
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
assert_eq!(response.bytes().await?.as_ref(), b"plain http");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Root credentials have no attached IAM policy, so these requests depend on
|
||||
/// the owner flag reaching policy evaluation unchanged.
|
||||
#[tokio::test]
|
||||
async fn test_root_requests_keep_policy_owner_flag() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
init_logging();
|
||||
|
||||
let opa = OpaMock::start().await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
|
||||
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket = "root-owner-flag";
|
||||
let key = "owner.txt";
|
||||
let client = env.create_s3_client();
|
||||
client.create_bucket().bucket(bucket).send().await?;
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"owner access"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Duplicate stored tags are ambiguous. An owner request must not enter the
|
||||
// OPA tag-loading path, which rejects this metadata before evaluation.
|
||||
client
|
||||
.put_bucket_tagging()
|
||||
.bucket(bucket)
|
||||
.tagging(
|
||||
Tagging::builder()
|
||||
.tag_set(Tag::builder().key("department").value("finance").build()?)
|
||||
.tag_set(Tag::builder().key("department").value("engineering").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let object = client.get_object().bucket(bucket).key(key).send().await?;
|
||||
|
||||
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), b"owner access");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Issue #2036: Anonymous GetObject should succeed when bucket policy allows it
|
||||
/// and no PublicAccessBlock configuration exists (ConfigNotFound).
|
||||
#[tokio::test]
|
||||
|
||||
@@ -20,15 +20,14 @@
|
||||
//! offline before heal converges, a strict latest-listing quorum of 3 can only
|
||||
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
|
||||
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
|
||||
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853); the
|
||||
//! product fix relaxes the listing's required object quorum by the number of
|
||||
//! set drives the listing could not consult (see
|
||||
//! `latest_listing_required_object_quorum` in
|
||||
//! `crates/ecstore/src/store/list_objects.rs`).
|
||||
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853). The
|
||||
//! listing now follows MinIO's rule: an entry needs `ceil(asked/2)` copies
|
||||
//! among the online drives it asked, so three online drives need two copies
|
||||
//! (see `SetDisks::list_path` in `crates/ecstore/src/store/list_objects.rs`).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::common::{RustFSTestClusterEnvironment, init_logging};
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use bytes::Bytes;
|
||||
use std::collections::HashSet;
|
||||
@@ -51,13 +50,13 @@ mod tests {
|
||||
format!("degraded-object-{idx:02}")
|
||||
}
|
||||
|
||||
async fn list_all_keys(client: &Client) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
|
||||
async fn list_all_keys(client: &Client, bucket: &str) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
|
||||
let mut keys = HashSet::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
loop {
|
||||
let response = client
|
||||
.list_objects_v2()
|
||||
.bucket(BUCKET)
|
||||
.bucket(bucket)
|
||||
.set_continuation_token(continuation_token.clone())
|
||||
.send()
|
||||
.await?;
|
||||
@@ -147,7 +146,7 @@ mod tests {
|
||||
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||
let listed = loop {
|
||||
let listed = match list_all_keys(&client).await {
|
||||
let listed = match list_all_keys(&client, BUCKET).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
info!("retrying degraded listing: {error}");
|
||||
@@ -171,4 +170,97 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 4-node x 4-drive cluster (one 16-drive set), #8828 shape:
|
||||
/// 1. PUT objects while the storage class is EC:8, so each is 8+8.
|
||||
/// 2. Restart with the EC:4 default, as after a storage-class change.
|
||||
/// 3. Stop two nodes (8 drives). Each object keeps eight data shards, so
|
||||
/// GET succeeds and ListObjectsV2 must list every key: the listing
|
||||
/// quorum is half of the eight drives it can ask.
|
||||
/// 4. Stop a third node. Four online drives are below the set's read
|
||||
/// quorum, so LIST must report 503 instead of a partial page.
|
||||
#[tokio::test]
|
||||
async fn ec8_objects_stay_listable_with_half_of_an_ec4_set_offline() -> TestResult {
|
||||
const EC8_BUCKET: &str = "degraded-listing-ec8";
|
||||
init_logging();
|
||||
|
||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
|
||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:8");
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(EC8_BUCKET).await?;
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
for idx in 0..OBJECT_COUNT {
|
||||
let key = object_key(idx);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(EC8_BUCKET)
|
||||
.key(&key)
|
||||
.body(Bytes::from(format!("ec8 listing payload {idx}")).into())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("EC:8 PUT for {key} failed: {error}"))?;
|
||||
}
|
||||
|
||||
info!("restarting with the EC:4 default storage class");
|
||||
cluster.stop();
|
||||
// A later entry for the same variable wins when the node process starts.
|
||||
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4");
|
||||
cluster.start().await?;
|
||||
let client = cluster.create_s3_client(0)?;
|
||||
|
||||
info!("stopping nodes 2 and 3 (eight of sixteen drives)");
|
||||
cluster.stop_node(2)?;
|
||||
cluster.stop_node(3)?;
|
||||
for idx in 0..OBJECT_COUNT {
|
||||
let key = object_key(idx);
|
||||
let deadline = Instant::now() + GET_RETRY_DEADLINE;
|
||||
loop {
|
||||
match client.get_object().bucket(EC8_BUCKET).key(&key).send().await {
|
||||
Ok(response) => {
|
||||
let body = response.body.collect().await?.into_bytes();
|
||||
assert_eq!(body.as_ref(), format!("ec8 listing payload {idx}").as_bytes());
|
||||
break;
|
||||
}
|
||||
Err(error) if Instant::now() < deadline => {
|
||||
info!("retrying EC:8 GET for {key}: {error}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(error) => return Err(format!("EC:8 object {key} failed GET with eight drives: {error}").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
|
||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||
loop {
|
||||
match list_all_keys(&client, EC8_BUCKET).await {
|
||||
Ok(listed) if listed == expected => break,
|
||||
Ok(listed) => assert!(
|
||||
Instant::now() < deadline,
|
||||
"EC:8 objects readable with eight drives stayed missing from ListObjectsV2: listed={listed:?}"
|
||||
),
|
||||
Err(error) => assert!(
|
||||
Instant::now() < deadline,
|
||||
"ListObjectsV2 kept failing with eight of sixteen drives online: {error}"
|
||||
),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
info!("stopping node 1 (four of sixteen drives remain)");
|
||||
cluster.stop_node(1)?;
|
||||
let deadline = Instant::now() + LISTING_DEADLINE;
|
||||
loop {
|
||||
match client.list_objects_v2().bucket(EC8_BUCKET).send().await {
|
||||
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => break,
|
||||
Err(error) => assert!(Instant::now() < deadline, "below the set read quorum LIST must report 503, got {error}"),
|
||||
Ok(response) => panic!("below the set read quorum LIST must fail, but listed {} keys", response.contents().len()),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,9 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
|
||||
}
|
||||
|
||||
pub(super) fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
|
||||
if let Ok(filter) = std::env::var("RUSTFS_E2E_LOG_FILTER") {
|
||||
cluster.set_env("RUST_LOG", filter);
|
||||
}
|
||||
if let Some(directory) = capture_dir {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
for node_idx in 0..cluster.nodes.len() {
|
||||
|
||||
@@ -104,7 +104,12 @@ async fn verified_get(
|
||||
stage = "response body";
|
||||
let mut digest = Sha256::new();
|
||||
while let Some(chunk) = output.body.next().await {
|
||||
let chunk = chunk?;
|
||||
let chunk = chunk.inspect_err(|error| {
|
||||
eprintln!(
|
||||
"TLS acceptance: GET body failed: node={node}, key={}, range={:?}, concurrent={concurrent}, received={received}/{}, elapsed={:?}, error={error}",
|
||||
object.key, expected.range, expected.length, started.elapsed()
|
||||
);
|
||||
})?;
|
||||
received += u64::try_from(chunk.len())?;
|
||||
assert!(received <= expected.length, "node {node}: GET exceeded the requested length");
|
||||
digest.update(&chunk);
|
||||
|
||||
@@ -28,14 +28,17 @@ mod harness;
|
||||
mod heal_test;
|
||||
mod internode_tls_test;
|
||||
mod multipart_fence_acceptance_test;
|
||||
mod multipart_rpc_delay_test;
|
||||
mod object_lock_test;
|
||||
mod observability_test;
|
||||
mod publication_log;
|
||||
mod replication_delete_marker_test;
|
||||
mod replication_quota_test;
|
||||
mod s3_basic_test;
|
||||
mod s3_during_data_movement_test;
|
||||
mod scanner_heal_topology_fixture;
|
||||
mod scanner_heal_topology_test;
|
||||
mod scanner_heal_topology_tier_test;
|
||||
mod site_replication_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Follow the same append-only log files without rescanning previous JSON rows.
|
||||
|
||||
use same_file::Handle;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Captured RPC spans already produce valid rows above 2 MiB. Keep complete
|
||||
// rows bounded independently from the much smaller selected-event history.
|
||||
const MAX_LINE_BYTES: usize = 4 << 20;
|
||||
const MAX_EVENTS: usize = 128;
|
||||
const MAX_EVENT_BYTES: usize = 1 << 20;
|
||||
|
||||
fn invalid(reason: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, reason)
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct Selector {
|
||||
#[serde(default)]
|
||||
event: Value,
|
||||
#[serde(default)]
|
||||
op: Value,
|
||||
#[serde(default)]
|
||||
bucket: Value,
|
||||
#[serde(default)]
|
||||
object: Value,
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
fn matches(&self, bucket: &str, object: &str) -> bool {
|
||||
self.event.as_str() == Some("object_namespace_lock")
|
||||
&& self.op.as_str() == Some("data_movement_publication")
|
||||
&& self.bucket.as_str() == Some(bucket)
|
||||
&& self.object.as_str() == Some(object)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Probe {
|
||||
#[serde(default)]
|
||||
event: Value,
|
||||
#[serde(default)]
|
||||
op: Value,
|
||||
#[serde(default)]
|
||||
bucket: Value,
|
||||
#[serde(default)]
|
||||
object: Value,
|
||||
// A present null is not a missing fields object. The old reader uses the
|
||||
// root only when this key is absent, including for non-object JSON values.
|
||||
#[serde(default, deserialize_with = "present_fields")]
|
||||
fields: Option<Option<Selector>>,
|
||||
}
|
||||
|
||||
fn present_fields<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<Selector>>, D::Error> {
|
||||
Option::<Selector>::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn matches(&self, bucket: &str, object: &str) -> bool {
|
||||
match &self.fields {
|
||||
Some(Some(fields)) => fields.matches(bucket, object),
|
||||
Some(None) => false,
|
||||
None => {
|
||||
self.event.as_str() == Some("object_namespace_lock")
|
||||
&& self.op.as_str() == Some("data_movement_publication")
|
||||
&& self.bucket.as_str() == Some(bucket)
|
||||
&& self.object.as_str() == Some(object)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select(line: &[u8], bucket: &str, object: &str, full_decodes: &mut u64) -> io::Result<Option<Value>> {
|
||||
let text = std::str::from_utf8(line).map_err(|_| invalid("invalid UTF-8 in a completed log row"))?;
|
||||
// Skip unknown fields without allocating a Value tree for every RPC trace.
|
||||
// A shape/duplicate-field error falls back to the original Value parser;
|
||||
// it must not discard valid escaped strings or last-key-wins JSON.
|
||||
if let Ok(probe) = serde_json::from_str::<Probe>(text)
|
||||
&& !probe.matches(bucket, object)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
*full_decodes += 1;
|
||||
let Ok(row) = serde_json::from_str::<Value>(text) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let fields = row.get("fields").unwrap_or(&row);
|
||||
if fields.get("event").and_then(Value::as_str) != Some("object_namespace_lock")
|
||||
|| fields.get("op").and_then(Value::as_str) != Some("data_movement_publication")
|
||||
|| fields.get("bucket").and_then(Value::as_str) != Some(bucket)
|
||||
|| fields.get("object").and_then(Value::as_str) != Some(object)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mut event = fields.clone();
|
||||
event["timestamp"] = row["timestamp"].clone();
|
||||
Ok(Some(event))
|
||||
}
|
||||
|
||||
struct Tail {
|
||||
path: PathBuf,
|
||||
handle: Handle,
|
||||
offset: u64,
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Tail {
|
||||
fn open(path: PathBuf) -> io::Result<Self> {
|
||||
let file = File::open(&path)?;
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(invalid("publication log is not a regular file"));
|
||||
}
|
||||
Ok(Self {
|
||||
path,
|
||||
handle: Handle::from_file(file)?,
|
||||
offset: 0,
|
||||
pending: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn check_identity(&self) -> io::Result<()> {
|
||||
if Handle::from_path(&self.path)? != self.handle {
|
||||
return Err(invalid("publication log file was replaced"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll(&mut self, bytes_read: &mut u64, mut accept: impl FnMut(&[u8]) -> io::Result<()>) -> io::Result<()> {
|
||||
self.check_identity()?;
|
||||
let end = self.handle.as_file().metadata()?.len();
|
||||
if end < self.offset {
|
||||
return Err(invalid("publication log file was truncated"));
|
||||
}
|
||||
// A fixed high-water snapshot bounds each poll even if writers append
|
||||
// faster than the reader. Bytes after it remain for the next poll.
|
||||
let mut chunk = [0_u8; 64 * 1024];
|
||||
while self.offset < end {
|
||||
let size = usize::try_from((end - self.offset).min(64 * 1024)).expect("a log chunk fits usize");
|
||||
self.handle.as_file_mut().read_exact(&mut chunk[..size])?;
|
||||
self.offset += u64::try_from(size).expect("a bounded log read fits u64");
|
||||
*bytes_read += u64::try_from(size).expect("a bounded log read fits u64");
|
||||
let appended_at = self.pending.len();
|
||||
self.pending.extend_from_slice(&chunk[..size]);
|
||||
let mut start = 0;
|
||||
for end in self.pending[appended_at..]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, byte)| (*byte == b'\n').then_some(appended_at + i + 1))
|
||||
{
|
||||
if end - start > MAX_LINE_BYTES {
|
||||
return Err(invalid("publication log row exceeds its fixed bound"));
|
||||
}
|
||||
accept(&self.pending[start..end])?;
|
||||
start = end;
|
||||
}
|
||||
self.pending.drain(..start);
|
||||
if self.pending.len() > MAX_LINE_BYTES {
|
||||
return Err(invalid("unfinished publication log row exceeds its fixed bound"));
|
||||
}
|
||||
}
|
||||
self.check_identity()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PublicationEvents {
|
||||
tails: Vec<Tail>,
|
||||
bucket: String,
|
||||
object: String,
|
||||
events: Vec<Value>,
|
||||
per_file: Vec<Vec<Value>>,
|
||||
event_count: usize,
|
||||
event_bytes: usize,
|
||||
bytes_read: u64,
|
||||
full_decodes: u64,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl PublicationEvents {
|
||||
pub(super) fn new(paths: Vec<PathBuf>, bucket: &str, object: &str) -> io::Result<Self> {
|
||||
if paths.is_empty() || paths.len() > 4 {
|
||||
return Err(invalid("publication capture requires one to four log files"));
|
||||
}
|
||||
let tails: Vec<Tail> = paths.into_iter().map(Tail::open).collect::<io::Result<_>>()?;
|
||||
let mut reader = Self {
|
||||
per_file: vec![Vec::new(); tails.len()],
|
||||
tails,
|
||||
bucket: bucket.to_owned(),
|
||||
object: object.to_owned(),
|
||||
events: Vec::new(),
|
||||
event_bytes: 0,
|
||||
event_count: 0,
|
||||
bytes_read: 0,
|
||||
full_decodes: 0,
|
||||
failed: false,
|
||||
};
|
||||
// Prime before holding remote DELETE or starting a movement attempt.
|
||||
// Retain earlier matching events too: the caller still applies its
|
||||
// original timestamp boundary, and evidence keeps the complete history.
|
||||
reader.poll()?;
|
||||
Ok(reader)
|
||||
}
|
||||
|
||||
pub(super) fn poll(&mut self) -> io::Result<&[Value]> {
|
||||
if self.failed {
|
||||
return Err(invalid("publication log reader was already invalidated"));
|
||||
}
|
||||
let result = (|| {
|
||||
for (tail, history) in self.tails.iter_mut().zip(&mut self.per_file) {
|
||||
tail.poll(&mut self.bytes_read, |line| {
|
||||
if let Some(event) = select(line, &self.bucket, &self.object, &mut self.full_decodes)? {
|
||||
let bytes = serde_json::to_vec(&event).map_err(io::Error::other)?.len();
|
||||
if self.event_count >= MAX_EVENTS || bytes > MAX_EVENT_BYTES - self.event_bytes {
|
||||
return Err(invalid("publication event history exceeds its fixed bound"));
|
||||
}
|
||||
self.event_bytes += bytes;
|
||||
self.event_count += 1;
|
||||
history.push(event);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
// Preserve the old file-then-row order, even when a later poll appends
|
||||
// to an earlier file. Only bounded selected history is copied here.
|
||||
self.events = self.per_file.iter().flatten().cloned().collect();
|
||||
if let Err(error) = result {
|
||||
self.failed = true;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(&self.events)
|
||||
}
|
||||
|
||||
/// Diagnostics only: after a read error this history can be incomplete.
|
||||
pub(super) fn cached(&self) -> &[Value] {
|
||||
&self.events
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn event(id: &str) -> Value {
|
||||
json!({"timestamp":"2026-10-26T00:00:00Z","event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object","lock_attempt_id":id,"state":"acquiring","extra":{"kept":[1,2]}})
|
||||
}
|
||||
fn append(path: &std::path::Path, raw: &[u8]) {
|
||||
std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(path)
|
||||
.expect("open append log")
|
||||
.write_all(raw)
|
||||
.expect("append log bytes");
|
||||
}
|
||||
fn legacy(raw: &[u8]) -> Vec<Value> {
|
||||
std::str::from_utf8(raw)
|
||||
.expect("fixture UTF-8")
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let row: Value = serde_json::from_str(line).ok()?;
|
||||
let fields = row.get("fields").unwrap_or(&row);
|
||||
if fields.get("event").and_then(Value::as_str) == Some("object_namespace_lock")
|
||||
&& fields.get("op").and_then(Value::as_str) == Some("data_movement_publication")
|
||||
&& fields.get("bucket").and_then(Value::as_str) == Some("bucket")
|
||||
&& fields.get("object").and_then(Value::as_str) == Some("object")
|
||||
{
|
||||
let mut result = fields.clone();
|
||||
result["timestamp"] = row["timestamp"].clone();
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appended_events_keep_full_fields_without_history_rescans() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut raw = (json!({"message":"unrelated trace","payload":"x".repeat(4096)}).to_string() + "\n")
|
||||
.repeat(64)
|
||||
.into_bytes();
|
||||
raw.extend_from_slice(
|
||||
format!("{}\n", json!({"event":"object_namespace_lock","op":"get_object","bucket":"bucket","object":"object","spans":"x".repeat(2401832)})).as_bytes(),
|
||||
);
|
||||
raw.extend_from_slice(format!("{}\n", event("first")).as_bytes());
|
||||
std::fs::write(file.path(), &raw).expect("seed log");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
|
||||
assert_eq!(reader.cached(), legacy(&raw));
|
||||
assert_eq!(reader.full_decodes, 1);
|
||||
let bytes = reader.bytes_read;
|
||||
assert_eq!(reader.poll().expect("unchanged poll"), legacy(&raw));
|
||||
assert_eq!(reader.bytes_read, bytes);
|
||||
let next = format!("{}\n", event("second"));
|
||||
append(file.path(), next.as_bytes());
|
||||
raw.extend_from_slice(next.as_bytes());
|
||||
assert_eq!(reader.poll().expect("new rows"), legacy(&raw));
|
||||
assert_eq!(reader.bytes_read, u64::try_from(raw.len()).expect("fixture length"));
|
||||
assert_eq!(reader.full_decodes, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_appends_preserve_the_old_file_then_row_order() {
|
||||
let first = tempfile::NamedTempFile::new().expect("first log");
|
||||
let second = tempfile::NamedTempFile::new().expect("second log");
|
||||
std::fs::write(first.path(), format!("{}\n", event("a1"))).expect("seed first");
|
||||
std::fs::write(second.path(), format!("{}\n", event("b1"))).expect("seed second");
|
||||
let mut reader = PublicationEvents::new(vec![first.path().to_owned(), second.path().to_owned()], "bucket", "object")
|
||||
.expect("prime both");
|
||||
append(first.path(), format!("{}\n", event("a2")).as_bytes());
|
||||
let mut expected = legacy(&std::fs::read(first.path()).expect("first bytes"));
|
||||
expected.extend(legacy(&std::fs::read(second.path()).expect("second bytes")));
|
||||
assert_eq!(reader.poll().expect("append first after second was read"), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_nested_escaped_and_duplicate_fields_match_the_old_reader() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let root = event("root");
|
||||
let mut nested = json!({"timestamp":root["timestamp"],"fields":root});
|
||||
nested["fields"]["extra"] = json!({"unicode":"é"});
|
||||
let null = json!({"fields":null,"event":"object_namespace_lock","op":"data_movement_publication","bucket":"bucket","object":"object"});
|
||||
let escaped = root
|
||||
.to_string()
|
||||
.replace("object_namespace_lock", "\\u006fbject_namespace_lock");
|
||||
let duplicate = root.to_string().replacen("{", "{\"event\":\"earlier-value\",", 1);
|
||||
let raw = format!("{root}\n{nested}\n{null}\n{escaped}\n{duplicate}\nnot json\n{{\"fields\":[]}}\n");
|
||||
std::fs::write(file.path(), &raw).expect("mixed log");
|
||||
let reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("probe and fallback");
|
||||
assert_eq!(reader.cached(), legacy(raw.as_bytes()));
|
||||
assert_eq!(reader.cached().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_rows_and_split_utf8_wait_for_the_newline() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut row = event("partial");
|
||||
row["extra"] = json!("é");
|
||||
let raw = format!("{row}\n").into_bytes();
|
||||
let cut = raw.iter().position(|b| *b == 0xc3).expect("UTF-8 prefix") + 1;
|
||||
std::fs::write(file.path(), &raw[..cut]).expect("partial code point");
|
||||
let mut reader =
|
||||
PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("incomplete row is pending");
|
||||
assert!(reader.cached().is_empty());
|
||||
append(file.path(), &raw[cut..raw.len() - 1]);
|
||||
assert!(reader.poll().expect("complete JSON without delimiter").is_empty());
|
||||
append(file.path(), b"\n");
|
||||
assert_eq!(reader.poll().expect("published row"), legacy(&raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_sticky_even_after_the_file_grows_again() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
std::fs::write(file.path(), format!("{}\n", event("first"))).expect("seed log");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("prime log");
|
||||
file.as_file().set_len(0).expect("truncate");
|
||||
assert!(reader.poll().is_err());
|
||||
append(file.path(), format!("{}\n", event("longer replacement")).as_bytes());
|
||||
assert!(reader.poll().is_err());
|
||||
assert_eq!(reader.cached().len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_replaced_path_does_not_switch_to_the_new_log() {
|
||||
let dir = tempfile::tempdir().expect("log dir");
|
||||
let path = dir.path().join("node.log");
|
||||
std::fs::write(&path, b"").expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![path.clone()], "bucket", "object").expect("open log");
|
||||
std::fs::rename(&path, dir.path().join("retired.log")).expect("replace log");
|
||||
std::fs::write(&path, format!("{}\n", event("other-generation"))).expect("replacement");
|
||||
assert!(reader.poll().is_err());
|
||||
assert!(reader.cached().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_and_oversized_or_excessive_records_fail_closed() {
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
append(file.path(), b"\xff\n");
|
||||
assert!(reader.poll().is_err());
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
append(file.path(), &vec![b'x'; MAX_LINE_BYTES + 1]);
|
||||
assert!(reader.poll().is_err());
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
for _ in 0..=MAX_EVENTS {
|
||||
append(file.path(), format!("{}\n", event("attempt")).as_bytes());
|
||||
}
|
||||
assert!(reader.poll().is_err());
|
||||
assert_eq!(reader.cached().len(), MAX_EVENTS);
|
||||
assert!(reader.poll().is_err());
|
||||
let file = tempfile::NamedTempFile::new().expect("log file");
|
||||
let mut reader = PublicationEvents::new(vec![file.path().to_owned()], "bucket", "object").expect("empty log");
|
||||
let mut large = event("large");
|
||||
large["extra"] = json!("x".repeat(600 * 1024));
|
||||
for _ in 0..2 {
|
||||
append(file.path(), format!("{large}\n").as_bytes());
|
||||
}
|
||||
assert!(reader.poll().is_err());
|
||||
assert_eq!(reader.cached().len(), 1);
|
||||
assert!(reader.poll().is_err());
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,10 @@ pub(crate) struct TopologyRun {
|
||||
|
||||
impl TopologyRun {
|
||||
pub(crate) async fn start(label: &str) -> TestResult<Self> {
|
||||
Self::start_with_env(label, &[]).await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_with_env(label: &str, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
||||
let run_name = format!("scanner-heal-topology-{label}-{}", Uuid::new_v4().simple());
|
||||
// CI uploads RUSTFS_E2E_LOG_DIR; keep node logs, barrier observations
|
||||
// and evidence there so a failed lane can be diagnosed.
|
||||
@@ -236,6 +240,7 @@ impl TopologyRun {
|
||||
let mut env: Vec<(&str, &str)> = TOPOLOGY_ENV.to_vec();
|
||||
env.extend_from_slice(FAST_DATA_USAGE_SCANNER_ENV);
|
||||
env.push((MOVEMENT_BARRIER_DIR_ENV, movement_dir_value.as_str()));
|
||||
env.extend_from_slice(extra_env);
|
||||
let mut dist = DistCluster::new_stopped_with_env(DistLayout::SingleNodeFourDrive, &env).await?;
|
||||
dist.capture_logs_under(&log_dir)?;
|
||||
dist.cluster.start().await?;
|
||||
@@ -329,6 +334,11 @@ impl FileBarrier {
|
||||
&self.nonce
|
||||
}
|
||||
|
||||
/// Keep the previous phase's release handle while arming a fresh nonce.
|
||||
pub(crate) fn next_phase(&self) -> Self {
|
||||
Self::new(self.dir.clone())
|
||||
}
|
||||
|
||||
fn write_atomic(&self, name: &str, body: &[u8]) -> TestResult {
|
||||
let tmp = self.dir.join(format!(".{name}.{}.tmp", Uuid::new_v4().simple()));
|
||||
std::fs::write(&tmp, body)?;
|
||||
@@ -360,6 +370,10 @@ impl FileBarrier {
|
||||
self.write_atomic(&format!("release.{}", self.nonce), b"io_error")
|
||||
}
|
||||
|
||||
pub(crate) fn release_movement(&self) -> TestResult {
|
||||
self.write_atomic(&format!("release.{}", self.nonce), b"continue")
|
||||
}
|
||||
|
||||
pub(crate) fn reached_observations(&self) -> TestResult<Vec<Value>> {
|
||||
let prefix = format!("reached.{}.", self.nonce);
|
||||
let mut observations = Vec::new();
|
||||
|
||||
@@ -528,7 +528,7 @@ async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_w
|
||||
run.movement_barrier.disarm()?;
|
||||
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
|
||||
let label = "after the killed leader resumed decommission";
|
||||
let outcome = settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await;
|
||||
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
// 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.
|
||||
|
||||
//! G-TIER: real mover processes contend with a held remote DELETE while a
|
||||
//! free version owns the old remote tuple beside a live transitioned version.
|
||||
//! The remote is the HTTP fake S3 target, not a provider interoperability test.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DRIVES_PER_NODE, TestResult, cluster_admin_ok, decommission_status_json, enable_versioning,
|
||||
rebalance_status_json, sha256_hex, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||
wait_for_rebalance_complete,
|
||||
};
|
||||
use super::publication_log::PublicationEvents;
|
||||
use super::scanner_heal_topology_fixture::{
|
||||
MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL, STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP,
|
||||
TopologyRun, assert_bucket_durability_strict, assert_process_durability_strict, require_test_hooks,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation, RequestRecord};
|
||||
use crate::storage_api::OBJECT_LOCK_TRACE_FILTER;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, Transition, TransitionStorageClass,
|
||||
};
|
||||
use http::Method;
|
||||
use rustfs_filemeta::{FileMeta, TransitionVersionState};
|
||||
use rustfs_utils::http::{SUFFIX_TRANSITION_TIER_DESTINATION_ID, get_consistent_str};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tokio::time::{Instant, sleep, timeout, timeout_at};
|
||||
|
||||
const TIER: &str = "TOPOLOGYCOLD";
|
||||
const KEY: &str = "000-tier-owner.bin";
|
||||
const TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
// Free-version cleanup holds its locks for at most 30 s during remote DELETE.
|
||||
// Fail before that deadline instead of confusing a retry with the held attempt.
|
||||
const DELETE_OVERLAP_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
struct RemoteTuple {
|
||||
bucket: String,
|
||||
key: String,
|
||||
version: String,
|
||||
tier: String,
|
||||
destination: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Serialize)]
|
||||
struct Owner {
|
||||
pool: usize,
|
||||
drive: usize,
|
||||
version: String,
|
||||
free: bool,
|
||||
remote: RemoteTuple,
|
||||
}
|
||||
|
||||
fn owners(run: &TopologyRun, bucket: &str, remote_bucket: &str) -> TestResult<Vec<Owner>> {
|
||||
let mut owners = Vec::new();
|
||||
for node in &run.dist.cluster.nodes {
|
||||
for (drive, root) in node.data_dirs.iter().enumerate() {
|
||||
let bytes = match std::fs::read(Path::new(root).join(bucket).join(KEY).join("xl.meta")) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
for version in FileMeta::load(&bytes)?.get_file_info_versions(bucket, KEY, true)?.versions {
|
||||
if version.transition_status != "complete" && !version.tier_free_version() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(version.transition_version_state, TransitionVersionState::Exact);
|
||||
owners.push(Owner {
|
||||
pool: node.pool_idx,
|
||||
drive,
|
||||
version: version.version_id.ok_or("tier owner omitted its local version")?.to_string(),
|
||||
free: version.tier_free_version(),
|
||||
remote: RemoteTuple {
|
||||
bucket: remote_bucket.to_string(),
|
||||
key: version.transitioned_objname.clone(),
|
||||
version: version
|
||||
.transition_version
|
||||
.clone()
|
||||
.ok_or("tier owner omitted its remote version")?,
|
||||
tier: version.transition_tier.clone(),
|
||||
destination: get_consistent_str(&version.metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
|
||||
.ok_or("tier owner omitted its destination identity")?
|
||||
.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(owners)
|
||||
}
|
||||
|
||||
fn assert_remote(target: &FakeS3Target, remote: &RemoteTuple, expected: &[u8]) -> TestResult {
|
||||
assert_eq!(target.stored_versions(&remote.bucket, &remote.key), vec![(remote.version.clone(), false)]);
|
||||
let (body, metadata) = target
|
||||
.stored_object(&remote.bucket, &remote.key)
|
||||
.ok_or("remote bytes are missing")?;
|
||||
assert_eq!(body.as_ref(), expected, "remote bytes changed for {remote:?}");
|
||||
assert_eq!(
|
||||
get_consistent_str(&metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID),
|
||||
Some(remote.destination.as_str())
|
||||
);
|
||||
assert_eq!(remote.tier, TIER);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn transition_version(
|
||||
run: &TopologyRun,
|
||||
target: &FakeS3Target,
|
||||
bucket: &str,
|
||||
remote_bucket: &str,
|
||||
body: &[u8],
|
||||
) -> TestResult<(String, RemoteTuple)> {
|
||||
let client = run.dist.client(0)?;
|
||||
let put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.body(ByteStream::from(body.to_vec()))
|
||||
.send()
|
||||
.await?;
|
||||
let version = put.version_id().ok_or("versioned PUT omitted its version")?.to_string();
|
||||
cluster_admin_ok(
|
||||
&run.dist.cluster,
|
||||
Method::POST,
|
||||
&format!("/rustfs/admin/v3/ilm/transition/run?bucket={bucket}&prefix={KEY}&tier={TIER}&dryRun=false"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let deadline = Instant::now() + TRANSITION_TIMEOUT;
|
||||
loop {
|
||||
let current = owners(run, bucket, remote_bucket)?;
|
||||
let shards: Vec<_> = current
|
||||
.iter()
|
||||
.filter(|owner| owner.version == version && !owner.free)
|
||||
.collect();
|
||||
if let Some(first) = shards.first()
|
||||
&& shards.len() == DRIVES_PER_NODE
|
||||
&& shards
|
||||
.iter()
|
||||
.all(|owner| owner.pool == SEED_POOL && owner.remote == first.remote)
|
||||
{
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.version_id(&version)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(head.storage_class().map(|class| class.as_str()), Some(TIER));
|
||||
assert_remote(target, &first.remote, body)?;
|
||||
return Ok((version, first.remote.clone()));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!("transition of {bucket}/{KEY}@{version} did not reach every seed drive: {current:?}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn event_time(event: &Value) -> TestResult<OffsetDateTime> {
|
||||
Ok(OffsetDateTime::parse(
|
||||
event["timestamp"].as_str().ok_or("lock event omitted its timestamp")?,
|
||||
&Rfc3339,
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn pending_publication(events: &mut PublicationEvents, started: OffsetDateTime) -> TestResult<Value> {
|
||||
loop {
|
||||
let observed = events.poll()?;
|
||||
for event in observed {
|
||||
if event_time(event)? < started {
|
||||
continue;
|
||||
}
|
||||
assert_ne!(
|
||||
event["state"], "acquired",
|
||||
"mover acquired its publication lock while remote DELETE was held"
|
||||
);
|
||||
if event["state"] == "acquiring"
|
||||
&& !observed
|
||||
.iter()
|
||||
.any(|later| later["lock_attempt_id"] == event["lock_attempt_id"] && later["state"] != "acquiring")
|
||||
{
|
||||
assert!(event["lock_attempt_id"].as_str().is_some_and(|id| !id.is_empty()));
|
||||
return Ok(event.clone());
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn acquired_publication(events: &mut PublicationEvents, pending: &Value) -> TestResult<Value> {
|
||||
loop {
|
||||
for event in events.poll()? {
|
||||
if event["lock_attempt_id"] != pending["lock_attempt_id"] {
|
||||
continue;
|
||||
}
|
||||
match event["state"].as_str() {
|
||||
Some("acquired") => return Ok(event.clone()),
|
||||
Some("failed" | "cancelled") => return Err(format!("held publication attempt did not acquire: {event}").into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn deletes(target: &FakeS3Target, remote: &RemoteTuple) -> Vec<RequestRecord> {
|
||||
target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|request| {
|
||||
request.operation == Operation::DeleteObject
|
||||
&& request.bucket == remote.bucket
|
||||
&& request.key.as_deref() == Some(remote.key.as_str())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn verify_live(run: &TopologyRun, bucket: &str, live_version: &str, old_version: &str, body: &[u8]) -> TestResult {
|
||||
for client in run.dist.clients()? {
|
||||
let got = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.version_id(live_version)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(got.body.collect().await?.into_bytes().as_ref(), body);
|
||||
let latest = client.head_object().bucket(bucket).key(KEY).send().await?;
|
||||
assert_eq!(latest.version_id(), Some(live_version));
|
||||
assert_eq!(latest.storage_class().map(|class| class.as_str()), Some(TIER));
|
||||
let deleted = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(KEY)
|
||||
.version_id(old_version)
|
||||
.send()
|
||||
.await;
|
||||
let error = deleted.expect_err("deleted source version must not be visible");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
let listed = client.list_object_versions().bucket(bucket).prefix(KEY).send().await?;
|
||||
assert_eq!(listed.versions().len(), 1, "free owners must not appear as object versions");
|
||||
assert_eq!(listed.versions()[0].version_id(), Some(live_version));
|
||||
assert!(
|
||||
listed.delete_markers().is_empty(),
|
||||
"moving the free owner must not create a delete marker"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn tier_delete_during_movement(decommission: bool) -> TestResult {
|
||||
init_logging();
|
||||
require_test_hooks();
|
||||
let operation = if decommission { "decommission" } else { "rebalance" };
|
||||
let target = FakeS3Target::start().await?;
|
||||
let remote_bucket = unique_bucket("sht-cold");
|
||||
target.create_bucket(&remote_bucket);
|
||||
let mut run = TopologyRun::start_with_env(
|
||||
&format!("tier-{operation}"),
|
||||
&[
|
||||
("RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT", "true"),
|
||||
("RUSTFS_OBJECT_LOCK_DIAG_ENABLE", "true"),
|
||||
("RUST_LOG", OBJECT_LOCK_TRACE_FILTER),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let tier = json!({
|
||||
"type": "rustfs",
|
||||
"rustfs": {
|
||||
"name": TIER, "endpoint": target.endpoint(), "accessKey": FAKE_ACCESS_KEY,
|
||||
"secretKey": FAKE_SECRET_KEY, "bucket": remote_bucket, "prefix": "",
|
||||
"region": "us-east-1", "storageClass": ""
|
||||
}
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
match cluster_admin_ok(&run.dist.cluster, Method::PUT, "/rustfs/admin/v3/tier", Some(tier.to_string())).await {
|
||||
Ok(_) => break,
|
||||
Err(_) if Instant::now() < deadline => sleep(Duration::from_millis(250)).await,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let bucket = unique_bucket("sht-tier");
|
||||
run.dist.create_bucket(&bucket).await?;
|
||||
let client = run.dist.client(0)?;
|
||||
enable_versioning(&client, &bucket).await?;
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("tier-owner")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix(KEY).build())
|
||||
.transitions(
|
||||
Transition::builder()
|
||||
.days(0)
|
||||
.storage_class(TransitionStorageClass::from(TIER))
|
||||
.build(),
|
||||
)
|
||||
.build()?;
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(&bucket)
|
||||
.lifecycle_configuration(BucketLifecycleConfiguration::builder().rules(rule).build()?)
|
||||
.send()
|
||||
.await?;
|
||||
let old_body = vec![0x31; 4096];
|
||||
let live_body = vec![0x92; 8192];
|
||||
let (old_version, old_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &old_body).await?;
|
||||
let (live_version, live_remote) = transition_version(&run, &target, &bucket, &remote_bucket, &live_body).await?;
|
||||
assert_ne!(
|
||||
old_remote.key, live_remote.key,
|
||||
"the obsolete and live versions need distinct remote tuples"
|
||||
);
|
||||
// Keep the seed pool above the rebalance goal until the first key is visited.
|
||||
// These local objects are outside the transition rule's prefix.
|
||||
for index in 0..16 {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(format!("zz-load/{index:02}.bin"))
|
||||
.body(ByteStream::from(vec![index as u8; 1024 * 1024]))
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||
run.dist.expand_to_four_pools().await?;
|
||||
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||
let seeded = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(seeded.len(), 2 * DRIVES_PER_NODE);
|
||||
assert!(seeded.iter().all(|owner| owner.pool == SEED_POOL && !owner.free));
|
||||
|
||||
let mut decommission_start = Box::pin(start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID));
|
||||
let mut startup = Value::Null;
|
||||
if decommission {
|
||||
run.movement_barrier.arm_movement(
|
||||
"decommission_start",
|
||||
".rustfs.sys",
|
||||
"pool.bin",
|
||||
STAGE_AFTER_RESPONSIBILITY_PERSISTED,
|
||||
)?;
|
||||
let requested_at = OffsetDateTime::now_utc();
|
||||
let preparation_started = Instant::now();
|
||||
let reached = tokio::select! {
|
||||
reached = run.movement_barrier.wait_reached(MOVEMENT_COMPLETE_TIMEOUT) => {
|
||||
reached.map_err(|error| format!(
|
||||
"G-TIER decommission start preparation failed before DELETE: {error}"
|
||||
))?
|
||||
}
|
||||
result = &mut decommission_start => {
|
||||
return Err(format!(
|
||||
"G-TIER decommission start preparation returned before durable-start barrier {}: {result:?}",
|
||||
run.movement_barrier.nonce()
|
||||
).into());
|
||||
}
|
||||
};
|
||||
startup = json!({
|
||||
"requested_at": requested_at.format(&Rfc3339)?,
|
||||
"parked_at": OffsetDateTime::now_utc().format(&Rfc3339)?,
|
||||
"preparation_elapsed_ms": preparation_started.elapsed().as_millis(),
|
||||
"barrier": reached,
|
||||
});
|
||||
}
|
||||
// The durable-start barrier has released the movement writer but has not
|
||||
// started a worker. Install a separate release nonce before holding DELETE.
|
||||
let cleanup_barrier = run.movement_barrier.next_phase();
|
||||
cleanup_barrier.arm_movement(operation, &bucket, KEY, STAGE_BEFORE_SOURCE_CLEANUP)?;
|
||||
let mut publication_events = PublicationEvents::new(run.node_log_paths(), &bucket, KEY)?;
|
||||
let mut delete_gate = target.hold_delete_object(&remote_bucket, &old_remote.key);
|
||||
// The public DELETE persists a free owner and enqueues its remote cleanup.
|
||||
let client = run.dist.client(0)?;
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(&bucket)
|
||||
.key(KEY)
|
||||
.version_id(&old_version)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("G-TIER {operation} public DELETE before remote gate failed: {error}; startup={startup}"))?;
|
||||
timeout(TRANSITION_TIMEOUT, delete_gate.wait_until_entered())
|
||||
.await
|
||||
.map_err(|error| format!("G-TIER {operation} DELETE gate entry timed out: {error}; startup={startup}"))?
|
||||
.map_err(|error| format!("G-TIER {operation} DELETE gate entry failed: {error}; startup={startup}"))?;
|
||||
let delete_gate_entered_at = OffsetDateTime::now_utc();
|
||||
let overlap_started = Instant::now();
|
||||
let overlap_deadline = overlap_started + DELETE_OVERLAP_TIMEOUT;
|
||||
let held = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(held.len(), 2 * DRIVES_PER_NODE);
|
||||
let old_owners: Vec<_> = held.iter().filter(|owner| owner.remote == old_remote).collect();
|
||||
assert_eq!(old_owners.len(), DRIVES_PER_NODE);
|
||||
assert!(
|
||||
old_owners
|
||||
.iter()
|
||||
.all(|owner| owner.pool == SEED_POOL && owner.free && owner.version != old_version)
|
||||
);
|
||||
assert_eq!(old_owners.iter().map(|owner| &owner.version).collect::<BTreeSet<_>>().len(), 1);
|
||||
assert_eq!(
|
||||
held.iter()
|
||||
.filter(|owner| {
|
||||
owner.pool == SEED_POOL && !owner.free && owner.version == live_version && owner.remote == live_remote
|
||||
})
|
||||
.count(),
|
||||
DRIVES_PER_NODE
|
||||
);
|
||||
assert_remote(&target, &old_remote, &old_body)?;
|
||||
assert_remote(&target, &live_remote, &live_body)?;
|
||||
let held_deletes = deletes(&target, &old_remote);
|
||||
assert!(!held_deletes.is_empty());
|
||||
assert!(held_deletes.iter().all(|request| {
|
||||
request.prepared_response.is_none() && request.version_id.as_deref() == Some(old_remote.version.as_str())
|
||||
}));
|
||||
assert!(deletes(&target, &live_remote).is_empty());
|
||||
|
||||
let movement_started = OffsetDateTime::now_utc();
|
||||
if decommission {
|
||||
startup["released_at"] = json!(movement_started.format(&Rfc3339)?);
|
||||
run.movement_barrier.release_movement()?;
|
||||
}
|
||||
let rebalance_id = timeout_at(overlap_deadline, async {
|
||||
if decommission {
|
||||
decommission_start.as_mut().await?;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(None)
|
||||
} else {
|
||||
Ok(Some(start_rebalance(&run.dist.cluster).await?))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!("G-TIER {operation} start acknowledgment timed out within DELETE overlap: {error}; startup={startup}")
|
||||
})?
|
||||
.map_err(|error| {
|
||||
format!("G-TIER {operation} start acknowledgment failed within DELETE overlap: {error}; startup={startup}")
|
||||
})?;
|
||||
drop(decommission_start);
|
||||
let movement_acknowledged_at = OffsetDateTime::now_utc();
|
||||
let pending = timeout_at(overlap_deadline, pending_publication(&mut publication_events, movement_started))
|
||||
.await
|
||||
.map_err(|error| format!(
|
||||
"G-TIER {operation} publication pending timed out within DELETE overlap: {error}; startup={startup}; lock_events={:?}; cleanup_barrier={:?}",
|
||||
publication_events.cached(), cleanup_barrier.reached_observations()
|
||||
))?
|
||||
.map_err(|error| format!("G-TIER {operation} publication pending failed within DELETE overlap: {error}"))?;
|
||||
// Positive gate entry, exact physical ownership and the pending lock attempt
|
||||
// establish the overlap; an absent log alone is not an ordering assertion.
|
||||
assert_eq!(owners(&run, &bucket, &remote_bucket)?, held);
|
||||
assert_remote(&target, &old_remote, &old_body)?;
|
||||
let released_at = OffsetDateTime::now_utc();
|
||||
let delete_overlap_elapsed_ms = overlap_started.elapsed().as_millis();
|
||||
drop(delete_gate);
|
||||
let acquired = timeout(DELETE_OVERLAP_TIMEOUT, acquired_publication(&mut publication_events, &pending))
|
||||
.await
|
||||
.map_err(|error| format!(
|
||||
"G-TIER {operation} publication acquired after DELETE release timed out: {error}; pending={pending}; lock_events={:?}; cleanup_barrier={:?}",
|
||||
publication_events.cached(), cleanup_barrier.reached_observations()
|
||||
))?
|
||||
.map_err(|error| format!("G-TIER {operation} publication acquired after DELETE release failed: {error}"))?;
|
||||
assert!(event_time(&pending)? >= movement_started && event_time(&pending)? < released_at);
|
||||
assert!(
|
||||
event_time(&acquired)? >= released_at,
|
||||
"the same publication attempt must acquire after DELETE is released"
|
||||
);
|
||||
drop(publication_events);
|
||||
let reached = cleanup_barrier
|
||||
.wait_reached_during(
|
||||
&run.dist.cluster,
|
||||
match rebalance_id.as_deref() {
|
||||
Some(id) => MovementOperation::Rebalance(id),
|
||||
None => MovementOperation::Decommission(DECOMMISSION_POOL_ID),
|
||||
},
|
||||
MOVEMENT_COMPLETE_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("G-TIER {operation} before-source-cleanup barrier failed: {error}"))?;
|
||||
let published = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
|
||||
);
|
||||
let publication_pools: BTreeSet<_> = published.iter().map(|owner| owner.pool).collect();
|
||||
assert!(publication_pools.contains(&SEED_POOL) && publication_pools.iter().any(|pool| *pool != SEED_POOL));
|
||||
assert_eq!(published.len(), publication_pools.len() * DRIVES_PER_NODE);
|
||||
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
|
||||
assert_remote(&target, &live_remote, &live_body)?;
|
||||
cleanup_barrier.release_movement()?;
|
||||
cleanup_barrier.disarm()?;
|
||||
let completion = match rebalance_id.as_deref() {
|
||||
Some(id) => {
|
||||
wait_for_rebalance_complete(&run.dist.cluster, id, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||
rebalance_status_json(&run.dist.cluster).await?
|
||||
}
|
||||
None => {
|
||||
wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await?;
|
||||
decommission_status_json(&run.dist.cluster).await?
|
||||
}
|
||||
};
|
||||
let settled = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(settled.len(), DRIVES_PER_NODE);
|
||||
assert_eq!(settled.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
|
||||
assert!(
|
||||
settled
|
||||
.iter()
|
||||
.all(|owner| owner.pool != SEED_POOL && !owner.free && owner.remote == live_remote)
|
||||
);
|
||||
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
|
||||
if decommission {
|
||||
run.dist.remove_retired_pools_and_restart(&[SEED_POOL]).await?;
|
||||
} else {
|
||||
run.dist.restart_current_binary_gracefully().await?;
|
||||
}
|
||||
verify_live(&run, &bucket, &live_version, &old_version, &live_body).await?;
|
||||
let restarted = owners(&run, &bucket, &remote_bucket)?;
|
||||
assert_eq!(restarted.len(), DRIVES_PER_NODE);
|
||||
assert_eq!(restarted.iter().map(|owner| owner.pool).collect::<BTreeSet<_>>().len(), 1);
|
||||
assert!(
|
||||
restarted
|
||||
.iter()
|
||||
.all(|owner| !owner.free && owner.version == live_version && owner.remote == live_remote)
|
||||
);
|
||||
assert_remote(&target, &live_remote, &live_body)?;
|
||||
assert!(target.stored_versions(&remote_bucket, &old_remote.key).is_empty());
|
||||
assert!(
|
||||
deletes(&target, &live_remote).is_empty(),
|
||||
"the live transitioned tuple must never receive DELETE"
|
||||
);
|
||||
let remote_deletes = deletes(&target, &old_remote);
|
||||
assert!(!remote_deletes.is_empty());
|
||||
assert!(
|
||||
remote_deletes
|
||||
.iter()
|
||||
.all(|request| request.version_id.as_deref() == Some(old_remote.version.as_str()))
|
||||
);
|
||||
let successful = remote_deletes
|
||||
.iter()
|
||||
.filter_map(|request| request.prepared_response.as_ref())
|
||||
.find(|response| response.status == 204)
|
||||
.ok_or("old remote tuple never completed DELETE")?;
|
||||
let deleted_at_ms = i128::from(
|
||||
successful
|
||||
.prepared_at_unix_millis
|
||||
.ok_or("DELETE response omitted its timestamp")?,
|
||||
);
|
||||
assert!(deleted_at_ms >= released_at.unix_timestamp_nanos() / 1_000_000);
|
||||
assert!(event_time(&acquired)?.unix_timestamp_nanos() / 1_000_000 >= deleted_at_ms);
|
||||
assert_bucket_durability_strict(&run.dist.cluster, &[&bucket]).await?;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
|
||||
run.write_evidence(
|
||||
&format!("g-tier-{operation}"),
|
||||
&json!({
|
||||
"gate": "G-TIER", "operation": operation, "rebalance_id": rebalance_id,
|
||||
"startup": startup,
|
||||
"delete_gate_entered_at": delete_gate_entered_at.format(&Rfc3339)?,
|
||||
"movement_overlap_started_at": movement_started.format(&Rfc3339)?,
|
||||
"movement_acknowledged_at": movement_acknowledged_at.format(&Rfc3339)?,
|
||||
"delete_overlap_timeout_ms": DELETE_OVERLAP_TIMEOUT.as_millis(),
|
||||
"delete_overlap_elapsed_ms": delete_overlap_elapsed_ms,
|
||||
"remote_backend": "HTTP fake_s3_target with versioning enabled",
|
||||
"ordering": "remote DELETE held, mover publication waiting, DELETE released, same publication acquired",
|
||||
"bucket": bucket, "key": KEY, "bucket_durability": bucket_durability,
|
||||
"process_durability_strict_nodes": strict_nodes,
|
||||
"old_version": old_version, "old_remote": old_remote, "old_sha256": sha256_hex(&old_body),
|
||||
"live_version": live_version, "live_remote": live_remote, "live_sha256": sha256_hex(&live_body),
|
||||
"owners_while_delete_held": held, "publication_waiting": pending, "publication_acquired": acquired,
|
||||
"held_delete_sequences": held_deletes.iter().map(|request| request.sequence).collect::<Vec<_>>(),
|
||||
"delete_released_at": released_at.format(&Rfc3339)?, "remote_delete_completed_at_unix_millis": deleted_at_ms,
|
||||
"barrier": reached, "owners_before_source_cleanup": published, "owners_after_movement": settled,
|
||||
"owners_after_restart": restarted, "completion": completion,
|
||||
"old_remote_delete_attempts": remote_deletes.len(), "live_remote_delete_attempts": 0,
|
||||
"source_readback_nodes": run.dist.cluster.nodes.len(),
|
||||
}),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_rebalance() -> TestResult {
|
||||
tier_delete_during_movement(false).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scanner_heal_topology_tier_free_owner_delete_overlaps_decommission() -> TestResult {
|
||||
tier_delete_during_movement(true).await
|
||||
}
|
||||
@@ -60,7 +60,7 @@ struct UpgradeSeed {
|
||||
iam_secret: &'static str,
|
||||
}
|
||||
|
||||
fn source_binary() -> TestResult<PathBuf> {
|
||||
pub(super) fn source_binary() -> TestResult<PathBuf> {
|
||||
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
|
||||
format!(
|
||||
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! What one stack answered, and how two answers are compared.
|
||||
//!
|
||||
//! An [`Observation`] keeps only what a client acts on: the HTTP status, the S3 error code, the
|
||||
//! allowlisted response headers and a few typed fields decoded from the response. The error
|
||||
//! message is kept for diagnostics and never compared: message wording is judged by the wire
|
||||
//! corpus, not here. The comparator is pure so it can be tested without a server.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Response headers compared exactly (after [`normalized_header_value`]).
|
||||
const HEADER_ALLOWLIST: [&str; 7] = [
|
||||
"content-type",
|
||||
"x-amz-version-id",
|
||||
"etag",
|
||||
"x-amz-delete-marker",
|
||||
"content-range",
|
||||
"accept-ranges",
|
||||
"x-amz-request-charged",
|
||||
];
|
||||
|
||||
/// Every response header with this prefix is compared as well.
|
||||
const HEADER_ALLOWLIST_PREFIX: &str = "x-amz-server-side-encryption";
|
||||
|
||||
/// Placeholder for a field one side did not send.
|
||||
const ABSENT: &str = "<absent>";
|
||||
|
||||
/// The lowercase header name if the comparator looks at this header.
|
||||
pub(crate) fn compared_header(name: &str) -> Option<String> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
(HEADER_ALLOWLIST.contains(&name.as_str()) || name.starts_with(HEADER_ALLOWLIST_PREFIX)).then_some(name)
|
||||
}
|
||||
|
||||
/// A version ID is minted per process, so its value never matches across two servers; only
|
||||
/// whether it is the literal `null` (an unversioned write) carries meaning.
|
||||
fn normalized_header_value(name: &str, value: &str) -> String {
|
||||
if name == "x-amz-version-id" && value != "null" {
|
||||
"<version-id>".to_owned()
|
||||
} else {
|
||||
value.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// One request's outcome as a client sees it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub(crate) struct Observation {
|
||||
/// The S3 operation, named as in the `s3s::S3` trait (`GetObject`).
|
||||
pub op: &'static str,
|
||||
/// Which request of the script this was (`normal`, `negative`, or a setup label).
|
||||
pub case: &'static str,
|
||||
/// HTTP status; 0 when no response arrived.
|
||||
pub status: u16,
|
||||
/// The S3 error code, or the SDK's error kind when there was no service error.
|
||||
pub error_code: Option<String>,
|
||||
/// Allowlisted headers only, lowercase names, normalized values.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Typed values decoded from the response body, never free text.
|
||||
pub fields: BTreeMap<String, String>,
|
||||
/// Diagnostic only; never compared.
|
||||
#[serde(skip)]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl Observation {
|
||||
/// Builds an observation, keeping only the allowlisted headers.
|
||||
pub(crate) fn new<'h>(
|
||||
op: &'static str,
|
||||
case: &'static str,
|
||||
status: u16,
|
||||
error_code: Option<String>,
|
||||
headers: impl IntoIterator<Item = (&'h str, &'h str)>,
|
||||
) -> Self {
|
||||
let headers = headers
|
||||
.into_iter()
|
||||
.filter_map(|(name, value)| compared_header(name).map(|name| (name, value)))
|
||||
.map(|(name, value)| {
|
||||
let value = normalized_header_value(&name, value);
|
||||
(name, value)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
op,
|
||||
case,
|
||||
status,
|
||||
error_code,
|
||||
headers,
|
||||
fields: BTreeMap::new(),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_field(mut self, name: &str, value: impl Into<String>) -> Self {
|
||||
self.fields.insert(name.to_owned(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// One field on which the two stacks answered differently.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct Difference {
|
||||
pub op: String,
|
||||
pub case: String,
|
||||
pub field: String,
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
}
|
||||
|
||||
/// Every difference between two transcripts of the same script, matched by `(op, case)`.
|
||||
pub(crate) fn compare(a: &[Observation], b: &[Observation]) -> Vec<Difference> {
|
||||
let mut differences = Vec::new();
|
||||
let (index_a, duplicates_a) = index(a);
|
||||
let (index_b, duplicates_b) = index(b);
|
||||
for (op, case) in duplicates_a.union(&duplicates_b) {
|
||||
let count = |side: &[Observation]| {
|
||||
side.iter()
|
||||
.filter(|observation| (observation.op, observation.case) == (*op, *case))
|
||||
.count()
|
||||
.to_string()
|
||||
};
|
||||
differences.push(difference(op, case, "duplicate_case", count(a), count(b)));
|
||||
}
|
||||
let keys: BTreeSet<_> = index_a.keys().chain(index_b.keys()).copied().collect();
|
||||
for key @ (op, case) in keys {
|
||||
match (index_a.get(&key), index_b.get(&key)) {
|
||||
(Some(a), Some(b)) => compare_one(a, b, &mut differences),
|
||||
(a, b) => {
|
||||
let presence = |side: Option<&&Observation>| if side.is_some() { "present" } else { ABSENT }.to_owned();
|
||||
differences.push(difference(op, case, "observation", presence(a), presence(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
differences
|
||||
}
|
||||
|
||||
type Key = (&'static str, &'static str);
|
||||
|
||||
fn index(side: &[Observation]) -> (BTreeMap<Key, &Observation>, BTreeSet<Key>) {
|
||||
let mut index = BTreeMap::new();
|
||||
let mut duplicates = BTreeSet::new();
|
||||
for observation in side {
|
||||
let key = (observation.op, observation.case);
|
||||
if index.insert(key, observation).is_some() {
|
||||
duplicates.insert(key);
|
||||
}
|
||||
}
|
||||
(index, duplicates)
|
||||
}
|
||||
|
||||
fn difference(op: &str, case: &str, field: &str, a: String, b: String) -> Difference {
|
||||
Difference {
|
||||
op: op.to_owned(),
|
||||
case: case.to_owned(),
|
||||
field: field.to_owned(),
|
||||
a,
|
||||
b,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_one(a: &Observation, b: &Observation, differences: &mut Vec<Difference>) {
|
||||
let mut field = |name: String, value_a: Option<&str>, value_b: Option<&str>| {
|
||||
if value_a != value_b {
|
||||
let show = |value: Option<&str>| value.unwrap_or(ABSENT).to_owned();
|
||||
differences.push(difference(a.op, a.case, &name, show(value_a), show(value_b)));
|
||||
}
|
||||
};
|
||||
field(
|
||||
"status".to_owned(),
|
||||
Some(a.status.to_string().as_str()),
|
||||
Some(b.status.to_string().as_str()),
|
||||
);
|
||||
field("error_code".to_owned(), a.error_code.as_deref(), b.error_code.as_deref());
|
||||
let headers: BTreeSet<_> = a.headers.keys().chain(b.headers.keys()).collect();
|
||||
for name in headers {
|
||||
field(
|
||||
format!("header.{name}"),
|
||||
a.headers.get(name).map(String::as_str),
|
||||
b.headers.get(name).map(String::as_str),
|
||||
);
|
||||
}
|
||||
let fields: BTreeSet<_> = a.fields.keys().chain(b.fields.keys()).collect();
|
||||
for name in fields {
|
||||
field(
|
||||
format!("body.{name}"),
|
||||
a.fields.get(name).map(String::as_str),
|
||||
b.fields.get(name).map(String::as_str),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Operations observed on both sides.
|
||||
pub(crate) fn compared_ops(a: &[Observation], b: &[Observation]) -> BTreeSet<&'static str> {
|
||||
let ops_b: BTreeSet<_> = b.iter().map(|observation| observation.op).collect();
|
||||
a.iter()
|
||||
.map(|observation| observation.op)
|
||||
.filter(|op| ops_b.contains(op))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `(op, case)` pairs observed on both sides.
|
||||
pub(crate) fn compared_cases(a: &[Observation], b: &[Observation]) -> usize {
|
||||
let cases_b: BTreeSet<_> = b.iter().map(|observation| (observation.op, observation.case)).collect();
|
||||
a.iter()
|
||||
.map(|observation| (observation.op, observation.case))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.intersection(&cases_b)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The JSON report written for every run.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct Report {
|
||||
pub schema: u32,
|
||||
pub run: String,
|
||||
pub stack_a: String,
|
||||
pub stack_b: String,
|
||||
pub region_a: Option<String>,
|
||||
pub region_b: Option<String>,
|
||||
pub compared_ops: usize,
|
||||
pub compared_cases: usize,
|
||||
pub differences: Vec<Difference>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Writes the report as `<directory>/<run>.json` and returns its path.
|
||||
pub(crate) fn write_to(&self, directory: &Path) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
let path = directory.join(format!("{}.json", self.run));
|
||||
let json = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
|
||||
std::fs::write(&path, json)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// One line per difference, for a failing assertion.
|
||||
pub(crate) fn render(differences: &[Difference]) -> String {
|
||||
differences
|
||||
.iter()
|
||||
.map(|d| format!("({}, {}, {}, a={:?}, b={:?})", d.op, d.case, d.field, d.a, d.b))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ok(op: &'static str) -> Observation {
|
||||
Observation::new(op, "normal", 200, None, [("content-type", "application/xml")])
|
||||
}
|
||||
|
||||
fn fields(differences: &[Difference]) -> Vec<&str> {
|
||||
differences.iter().map(|d| d.field.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_transcripts_have_no_difference() {
|
||||
let a = vec![
|
||||
ok("HeadBucket"),
|
||||
ok("GetBucketLocation").with_field("location_constraint", "us-east-1"),
|
||||
];
|
||||
let b = a.clone();
|
||||
assert_eq!(compare(&a, &b), Vec::new());
|
||||
assert_eq!(compared_ops(&a, &b).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_difference_is_reported() {
|
||||
let a = vec![ok("PutBucketAcl")];
|
||||
let b = vec![Observation::new(
|
||||
"PutBucketAcl",
|
||||
"normal",
|
||||
403,
|
||||
None,
|
||||
[("content-type", "application/xml")],
|
||||
)];
|
||||
assert_eq!(
|
||||
compare(&a, &b),
|
||||
vec![Difference {
|
||||
op: "PutBucketAcl".into(),
|
||||
case: "normal".into(),
|
||||
field: "status".into(),
|
||||
a: "200".into(),
|
||||
b: "403".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_code_difference_is_reported() {
|
||||
let a = vec![Observation::new("GetObject", "negative", 404, Some("NoSuchKey".into()), [])];
|
||||
let b = vec![Observation::new(
|
||||
"GetObject",
|
||||
"negative",
|
||||
404,
|
||||
Some("NoSuchBucket".into()),
|
||||
[],
|
||||
)];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["error_code"]);
|
||||
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("NoSuchKey", "NoSuchBucket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_code_on_one_side_only_is_reported() {
|
||||
let a = vec![Observation::new("HeadBucket", "negative", 404, Some("NotFound".into()), [])];
|
||||
let b = vec![Observation::new("HeadBucket", "negative", 404, None, [])];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["error_code"]);
|
||||
assert_eq!(differences[0].b, ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allowlisted_header_difference_is_reported() {
|
||||
let a = vec![Observation::new("HeadObject", "normal", 200, None, [("ETag", "\"aa\"")])];
|
||||
let b = vec![Observation::new("HeadObject", "normal", 200, None, [("etag", "\"bb\"")])];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["header.etag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_sent_by_one_side_only_is_reported() {
|
||||
let a = vec![Observation::new(
|
||||
"DeleteObject",
|
||||
"normal",
|
||||
204,
|
||||
None,
|
||||
[("x-amz-delete-marker", "true")],
|
||||
)];
|
||||
let b = vec![Observation::new("DeleteObject", "normal", 204, None, [])];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["header.x-amz-delete-marker"]);
|
||||
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("true", ABSENT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_server_side_encryption_header_is_compared() {
|
||||
let header = "x-amz-server-side-encryption-customer-key-md5";
|
||||
let a = vec![Observation::new("PutObject", "sse-c", 200, None, [(header, "one")])];
|
||||
let b = vec![Observation::new("PutObject", "sse-c", 200, None, [(header, "two")])];
|
||||
assert_eq!(fields(&compare(&a, &b)), [format!("header.{header}")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_outside_the_allowlist_are_not_compared() {
|
||||
let a = vec![Observation::new(
|
||||
"ListBuckets",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[
|
||||
("date", "Mon"),
|
||||
("x-amz-request-id", "1"),
|
||||
("server", "a"),
|
||||
("content-length", "10"),
|
||||
],
|
||||
)];
|
||||
let b = vec![Observation::new(
|
||||
"ListBuckets",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[
|
||||
("date", "Tue"),
|
||||
("x-amz-request-id", "2"),
|
||||
("server", "b"),
|
||||
("content-length", "11"),
|
||||
],
|
||||
)];
|
||||
assert_eq!(compare(&a, &b), Vec::new());
|
||||
assert!(a[0].headers.is_empty(), "non-allowlisted headers were kept: {:?}", a[0].headers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_version_ids_are_not_a_difference() {
|
||||
let a = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "0b1c")],
|
||||
)];
|
||||
let b = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "9f8e")],
|
||||
)];
|
||||
assert_eq!(compare(&a, &b), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_version_id_against_a_real_one_is_reported() {
|
||||
let a = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "null")],
|
||||
)];
|
||||
let b = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "9f8e")],
|
||||
)];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["header.x-amz-version-id"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_field_difference_is_reported() {
|
||||
let a = vec![ok("GetBucketLocation").with_field("location_constraint", "us-east-1")];
|
||||
let b = vec![ok("GetBucketLocation").with_field("location_constraint", "eu-west-1")];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["body.location_constraint"]);
|
||||
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("us-east-1", "eu-west-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_field_on_one_side_only_is_reported() {
|
||||
let a = vec![ok("ListObjectsV2").with_field("keys", "[]")];
|
||||
let b = vec![ok("ListObjectsV2")];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["body.keys"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_are_never_compared() {
|
||||
let mut a = Observation::new("GetObject", "negative", 404, Some("NoSuchKey".into()), []);
|
||||
let mut b = a.clone();
|
||||
a.message = Some("The specified key does not exist.".into());
|
||||
b.message = Some("Object not found".into());
|
||||
assert_eq!(compare(&[a], &[b]), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_case_observed_on_one_side_only_is_reported() {
|
||||
let a = vec![ok("HeadBucket"), ok("ListBuckets")];
|
||||
let b = vec![ok("HeadBucket")];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["observation"]);
|
||||
assert_eq!(differences[0].op, "ListBuckets");
|
||||
assert_eq!(compared_ops(&a, &b).into_iter().collect::<Vec<_>>(), ["HeadBucket"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_case_recorded_twice_is_reported() {
|
||||
let a = vec![ok("HeadBucket"), ok("HeadBucket")];
|
||||
let b = vec![ok("HeadBucket")];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["duplicate_case"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compared_ops_counts_operations_not_cases() {
|
||||
let negative = Observation::new("HeadBucket", "negative", 404, Some("NotFound".into()), []);
|
||||
let a = vec![ok("HeadBucket"), negative, ok("ListBuckets")];
|
||||
let b = a.clone();
|
||||
assert_eq!(compared_ops(&a, &b).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_lists_each_difference_as_op_field_a_b() {
|
||||
let directory = std::env::temp_dir().join(format!("dual-stack-report-{}", uuid::Uuid::new_v4()));
|
||||
let report = Report {
|
||||
schema: 1,
|
||||
run: "unit".into(),
|
||||
stack_a: "legacy".into(),
|
||||
stack_b: "legacy".into(),
|
||||
region_a: None,
|
||||
region_b: Some("eu-west-1".into()),
|
||||
compared_ops: 1,
|
||||
compared_cases: 1,
|
||||
differences: compare(
|
||||
&[ok("GetBucketLocation").with_field("location_constraint", "us-east-1")],
|
||||
&[ok("GetBucketLocation").with_field("location_constraint", "eu-west-1")],
|
||||
),
|
||||
};
|
||||
let path = report.write_to(&directory).expect("write the report");
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).expect("read the report")).expect("parse");
|
||||
let _ = std::fs::remove_dir_all(&directory);
|
||||
assert_eq!(path.file_name().and_then(|name| name.to_str()), Some("unit.json"));
|
||||
assert_eq!(json["compared_ops"], 1);
|
||||
assert_eq!(
|
||||
json["differences"],
|
||||
serde_json::json!([{
|
||||
"op": "GetBucketLocation",
|
||||
"case": "normal",
|
||||
"field": "body.location_constraint",
|
||||
"a": "us-east-1",
|
||||
"b": "eu-west-1",
|
||||
}])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Dual-stack differential: one `rustfs` binary, two processes, one SDK script (rustfs/backlog#2740).
|
||||
//!
|
||||
//! Stack A always runs `RUSTFS_S3_STACK=legacy`. Stack B runs the value of `RUSTFS_E2E_STACK_B`
|
||||
//! (default `legacy` until the gateway stack is assembled), and `RUSTFS_E2E_STACK_B_REGION`, when
|
||||
//! set, starts B in another region so that a difference can be injected by hand. Both servers
|
||||
//! answer the script in `scenarios`, which covers every operation of `impl S3 for FS`; `compare`
|
||||
//! reports each `(op, field, a, b)` on which the answers differ, and every run writes its report
|
||||
//! to `<target>/dual-stack/<run>.json`. Two processes, not two embedded servers: bucket metadata
|
||||
//! is process-global, so two stacks in one process would read each other's buckets.
|
||||
|
||||
mod compare;
|
||||
mod scenarios;
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, workspace_root};
|
||||
use compare::{Difference, Observation, Report, compare, compared_cases, compared_ops, render};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
const STACK_A: &str = "legacy";
|
||||
const ENV_STACK_B: &str = "RUSTFS_E2E_STACK_B";
|
||||
const ENV_STACK_B_REGION: &str = "RUSTFS_E2E_STACK_B_REGION";
|
||||
/// Becomes `gateway` once rustfs/backlog#2734 T2.5 assembles the gateway stack.
|
||||
const DEFAULT_STACK_B: &str = "legacy";
|
||||
/// The region the negative control gives stack A explicitly, so an inherited `RUSTFS_REGION`
|
||||
/// cannot erase the injected difference. Not `us-east-1`: that region answers GetBucketLocation
|
||||
/// with an empty LocationConstraint and re-creating an owned bucket with 200, so it would not
|
||||
/// report its own name and would add CreateBucket differences to the control.
|
||||
const CONTROL_REGION_A: &str = "us-west-2";
|
||||
const CONTROL_REGION_B: &str = "eu-west-1";
|
||||
/// `normal` cases the legacy stack must answer with a 2xx. The comparison alone cannot tell a
|
||||
/// script that exercises real behavior from one in which both stacks fail the same way. Five of
|
||||
/// the 73 fail on legacy by design of this script: PutBucketReplication (no registered remote
|
||||
/// target), GetBucketReplication (so no configuration), PutBucketNotificationConfiguration (no
|
||||
/// notification target configured), RestoreObject (the object is not archived) and
|
||||
/// GetObjectTorrent (legacy answers `NoSuchKey`).
|
||||
const MIN_NORMAL_SUCCESSES: usize = 68;
|
||||
|
||||
struct PairConfig {
|
||||
stack_b: String,
|
||||
region_a: Option<String>,
|
||||
region_b: Option<String>,
|
||||
}
|
||||
|
||||
impl PairConfig {
|
||||
fn from_env() -> Self {
|
||||
Self {
|
||||
stack_b: std::env::var(ENV_STACK_B).unwrap_or_else(|_| DEFAULT_STACK_B.to_owned()),
|
||||
region_a: None,
|
||||
region_b: std::env::var(ENV_STACK_B_REGION).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts one server on `stack`, overriding its region only when asked.
|
||||
async fn start_stack(stack: &str, region: Option<&str>) -> Result<RustFSTestEnvironment, BoxError> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let mut vars = vec![("RUSTFS_S3_STACK", stack)];
|
||||
if let Some(region) = region {
|
||||
vars.push(("RUSTFS_REGION", region));
|
||||
}
|
||||
env.start_rustfs_server_without_cleanup_with_env(&vars).await?;
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// Starts stack A and stack B side by side, each with its own port and data directory.
|
||||
async fn spawn_pair(config: &PairConfig) -> Result<(RustFSTestEnvironment, RustFSTestEnvironment), BoxError> {
|
||||
tokio::try_join!(
|
||||
start_stack(STACK_A, config.region_a.as_deref()),
|
||||
start_stack(&config.stack_b, config.region_b.as_deref())
|
||||
)
|
||||
}
|
||||
|
||||
fn report_directory() -> PathBuf {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| PathBuf::from("target"), PathBuf::from);
|
||||
let target = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
workspace_root().join(target)
|
||||
};
|
||||
target.join("dual-stack")
|
||||
}
|
||||
|
||||
struct Run {
|
||||
report: Report,
|
||||
path: PathBuf,
|
||||
a: Vec<Observation>,
|
||||
}
|
||||
|
||||
/// Runs the script against both stacks concurrently and writes the report.
|
||||
async fn run_differential(label: &str, config: &PairConfig) -> Run {
|
||||
let (server_a, server_b) = spawn_pair(config).await.expect("start stack A and stack B");
|
||||
let (a, b) = tokio::join!(Box::pin(scenarios::run(&server_a)), Box::pin(scenarios::run(&server_b)));
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| elapsed.as_millis());
|
||||
let report = Report {
|
||||
schema: 1,
|
||||
run: format!("{label}-{millis}-{}", std::process::id()),
|
||||
stack_a: STACK_A.to_owned(),
|
||||
stack_b: config.stack_b.clone(),
|
||||
region_a: config.region_a.clone(),
|
||||
region_b: config.region_b.clone(),
|
||||
compared_ops: compared_ops(&a, &b).len(),
|
||||
compared_cases: compared_cases(&a, &b),
|
||||
differences: compare(&a, &b),
|
||||
};
|
||||
let path = report.write_to(&report_directory()).expect("write the dual-stack report");
|
||||
Run { report, path, a }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn both_stacks_answer_every_operation_identically() {
|
||||
init_logging();
|
||||
let config = PairConfig::from_env();
|
||||
let run = run_differential("self-comparison", &config).await;
|
||||
let report = &run.report;
|
||||
|
||||
let mut observed: Vec<_> = run.a.iter().map(|observation| observation.op).collect();
|
||||
observed.sort_unstable();
|
||||
observed.dedup();
|
||||
assert_eq!(
|
||||
observed,
|
||||
scenarios::OPERATIONS,
|
||||
"the script must drive exactly the operations of impl S3 for FS"
|
||||
);
|
||||
assert_eq!(report.compared_ops, scenarios::OPERATIONS.len(), "report: {}", run.path.display());
|
||||
assert!(
|
||||
report.differences.is_empty(),
|
||||
"stack {} and stack {} differ on {} of {} cases (report: {}):\n{}",
|
||||
report.stack_a,
|
||||
report.stack_b,
|
||||
report.differences.len(),
|
||||
report.compared_cases,
|
||||
run.path.display(),
|
||||
render(&report.differences)
|
||||
);
|
||||
|
||||
let not_4xx: Vec<_> = run
|
||||
.a
|
||||
.iter()
|
||||
.filter(|observation| observation.case == "negative" && !(400..500).contains(&observation.status))
|
||||
.collect();
|
||||
assert!(not_4xx.is_empty(), "negative cases must fail with a 4xx on {STACK_A}: {not_4xx:#?}");
|
||||
let successes = run
|
||||
.a
|
||||
.iter()
|
||||
.filter(|observation| observation.case == "normal" && observation.is_success())
|
||||
.count();
|
||||
assert!(
|
||||
successes >= MIN_NORMAL_SUCCESSES,
|
||||
"only {successes} normal cases succeeded on {STACK_A}: {:#?}",
|
||||
run.a
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn differential_detects_an_injected_difference() {
|
||||
init_logging();
|
||||
let config = PairConfig {
|
||||
stack_b: STACK_A.to_owned(),
|
||||
region_a: Some(CONTROL_REGION_A.to_owned()),
|
||||
region_b: Some(CONTROL_REGION_B.to_owned()),
|
||||
};
|
||||
let run = run_differential("injected-region", &config).await;
|
||||
let expected = Difference {
|
||||
op: "GetBucketLocation".to_owned(),
|
||||
case: "normal".to_owned(),
|
||||
field: "body.location_constraint".to_owned(),
|
||||
a: CONTROL_REGION_A.to_owned(),
|
||||
b: CONTROL_REGION_B.to_owned(),
|
||||
};
|
||||
assert!(
|
||||
run.report.differences.contains(&expected),
|
||||
"B started in {CONTROL_REGION_B} but GetBucketLocation was not reported (report: {}):\n{}",
|
||||
run.path.display(),
|
||||
render(&run.report.differences)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -487,36 +487,43 @@ impl JournaledHeaders {
|
||||
struct ControlState {
|
||||
scripts: HashMap<Operation, VecDeque<FaultAction>>,
|
||||
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
|
||||
held_get: Option<HeldGetObject>,
|
||||
held_get: Option<HeldObject>,
|
||||
held_delete: Option<HeldObject>,
|
||||
requests: VecDeque<RequestRecord>,
|
||||
next_sequence: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HeldGetObject {
|
||||
struct HeldObject {
|
||||
bucket: String,
|
||||
key: String,
|
||||
entered: watch::Sender<usize>,
|
||||
released: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
/// Holds every GET of one object, including retries, until this guard is dropped.
|
||||
#[must_use = "dropping the guard releases the held GET requests"]
|
||||
pub struct GetObjectGate {
|
||||
/// Holds every matching operation, including retries, until this guard is dropped.
|
||||
#[must_use = "dropping the guard releases the held requests"]
|
||||
pub struct ObjectGate {
|
||||
control: Arc<Mutex<ControlState>>,
|
||||
operation: Operation,
|
||||
entered: watch::Receiver<usize>,
|
||||
released: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl GetObjectGate {
|
||||
impl ObjectGate {
|
||||
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
|
||||
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GetObjectGate {
|
||||
impl Drop for ObjectGate {
|
||||
fn drop(&mut self) {
|
||||
lock(&self.control).held_get = None;
|
||||
let mut state = lock(&self.control);
|
||||
match self.operation {
|
||||
Operation::GetObject => state.held_get = None,
|
||||
Operation::DeleteObject => state.held_delete = None,
|
||||
_ => unreachable!("only GET and DELETE have object gates"),
|
||||
}
|
||||
self.released.send_replace(true);
|
||||
}
|
||||
}
|
||||
@@ -1108,24 +1115,40 @@ impl FakeS3Target {
|
||||
}
|
||||
|
||||
/// Hold one exact bucket/key before any GET response can reach the client.
|
||||
/// The fixture supports one live gate; request and connection deadlines still apply.
|
||||
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
|
||||
/// The fixture supports one live GET gate; request and connection deadlines still apply.
|
||||
pub fn hold_get_object(&self, bucket: &str, key: &str) -> ObjectGate {
|
||||
self.hold_object_operation(Operation::GetObject, bucket, key)
|
||||
}
|
||||
|
||||
/// Hold one exact bucket/key before DELETE changes any stored version.
|
||||
/// The fixture supports one live DELETE gate; request deadlines still apply.
|
||||
pub fn hold_delete_object(&self, bucket: &str, key: &str) -> ObjectGate {
|
||||
self.hold_object_operation(Operation::DeleteObject, bucket, key)
|
||||
}
|
||||
|
||||
fn hold_object_operation(&self, operation: Operation, bucket: &str, key: &str) -> ObjectGate {
|
||||
assert!(
|
||||
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
|
||||
"held GET identifiers exceed the fixture limit"
|
||||
"held object identifiers exceed the fixture limit"
|
||||
);
|
||||
let mut state = lock(&self.control);
|
||||
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
|
||||
let slot = match operation {
|
||||
Operation::GetObject => &mut state.held_get,
|
||||
Operation::DeleteObject => &mut state.held_delete,
|
||||
_ => unreachable!("only GET and DELETE have object gates"),
|
||||
};
|
||||
assert!(slot.is_none(), "fake target already holds a {operation:?} gate");
|
||||
let (entered, entered_rx) = watch::channel(0);
|
||||
let (released, released_rx) = watch::channel(false);
|
||||
state.held_get = Some(HeldGetObject {
|
||||
*slot = Some(HeldObject {
|
||||
bucket: bucket.to_string(),
|
||||
key: key.to_string(),
|
||||
entered,
|
||||
released: released_rx,
|
||||
});
|
||||
GetObjectGate {
|
||||
ObjectGate {
|
||||
control: Arc::clone(&self.control),
|
||||
operation,
|
||||
entered: entered_rx,
|
||||
released,
|
||||
}
|
||||
@@ -2795,6 +2818,15 @@ impl S3 for FakeBackend {
|
||||
apply_non_body_fault(fault.as_ref(), &self.control).await?;
|
||||
let headers = req.headers;
|
||||
let input = req.input;
|
||||
let held_delete = lock(&self.control)
|
||||
.held_delete
|
||||
.as_ref()
|
||||
.filter(|held| held.bucket == input.bucket && held.key == input.key)
|
||||
.cloned();
|
||||
if let Some(mut held) = held_delete {
|
||||
held.entered.send_modify(|count| *count += 1);
|
||||
let _ = held.released.wait_for(|released| *released).await;
|
||||
}
|
||||
let mut state = lock(&self.store);
|
||||
let Some(versioned) = state.buckets.get(&input.bucket).map(|bucket| bucket.versioned) else {
|
||||
return Err(s3s::s3_error!(NoSuchBucket, "bucket does not exist"));
|
||||
@@ -3436,6 +3468,68 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_object_gate_retains_exact_version_until_release() -> Result<(), BoxError> {
|
||||
let target = FakeS3Target::start().await?;
|
||||
let bucket = "delete-gated-target";
|
||||
target.create_bucket(bucket);
|
||||
for key in ["held", "unrelated"] {
|
||||
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
|
||||
}
|
||||
let versions = target.stored_versions(bucket, "held");
|
||||
let mut gate = target.hold_delete_object(bucket, "held");
|
||||
let client = client(&target);
|
||||
let held_client = client.clone();
|
||||
let version = versions[0].0.clone();
|
||||
let mut requests = tokio::task::JoinSet::new();
|
||||
requests.spawn(async move {
|
||||
held_client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("held")
|
||||
.version_id(version)
|
||||
.send()
|
||||
.await
|
||||
});
|
||||
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
|
||||
assert_eq!(
|
||||
target.stored_versions(bucket, "held"),
|
||||
versions,
|
||||
"the gate must precede the storage mutation"
|
||||
);
|
||||
assert!(requests.try_join_next().is_none());
|
||||
let held = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == Operation::DeleteObject && record.key.as_deref() == Some("held"))
|
||||
.expect("the held DELETE must be journaled");
|
||||
assert_eq!(held.version_id.as_deref(), Some(versions[0].0.as_str()));
|
||||
assert_eq!(held.prepared_response, None);
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key("unrelated")
|
||||
.version_id(&target.stored_versions(bucket, "unrelated")[0].0)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(target.stored_versions(bucket, "unrelated").is_empty());
|
||||
assert_eq!(target.stored_versions(bucket, "held"), versions);
|
||||
drop(gate);
|
||||
timeout(Duration::from_secs(2), requests.join_next())
|
||||
.await?
|
||||
.expect("held DELETE task")??;
|
||||
assert!(target.stored_versions(bucket, "held").is_empty());
|
||||
let released = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.sequence == held.sequence)
|
||||
.and_then(|record| record.prepared_response)
|
||||
.expect("released DELETE response");
|
||||
assert_eq!(released.status, 204);
|
||||
assert_eq!(released.version_id.as_deref(), Some(versions[0].0.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
|
||||
@@ -21,7 +21,7 @@ mod tests {
|
||||
};
|
||||
use crate::common::{
|
||||
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
||||
init_logging, rustfs_binary_path,
|
||||
init_logging, local_http_client, rustfs_binary_path,
|
||||
};
|
||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||
use aws_sdk_s3::{
|
||||
@@ -53,6 +53,196 @@ mod tests {
|
||||
expected_pool_metadata: Option<VersionShardCensus>,
|
||||
}
|
||||
|
||||
fn pool_metadata_matches_republished_manifest(actual: &VersionShardCensus, expected: &VersionShardCensus) -> bool {
|
||||
for data_dir in [&actual.data_dir, &expected.data_dir] {
|
||||
if data_dir
|
||||
.as_deref()
|
||||
.and_then(|value| uuid::Uuid::parse_str(value).ok())
|
||||
.is_none_or(|value| value.is_nil())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The owning CAS republishes the same canonical payload with a fresh
|
||||
// physical identity. Every shard and layout field must still match.
|
||||
let mut republished = actual.clone();
|
||||
republished.data_dir = expected.data_dir.clone();
|
||||
republished.matches_manifest(expected)
|
||||
}
|
||||
|
||||
fn pool_metadata_node_health_is_ready(status: u16, payload: &serde_json::Value) -> bool {
|
||||
(200..300).contains(&status)
|
||||
&& payload["ready"] == true
|
||||
&& payload["details"]["poolMetadata"]["ready"] == true
|
||||
&& payload["details"]["storage"]["readQuorum"] == true
|
||||
&& payload["details"]["storage"]["writeQuorum"] == true
|
||||
}
|
||||
|
||||
async fn all_nodes_pool_metadata_readiness(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
deadline: Instant,
|
||||
) -> (bool, serde_json::Value) {
|
||||
let http = local_http_client();
|
||||
let mut ready = !cluster.nodes.is_empty();
|
||||
let mut observations = Vec::with_capacity(cluster.nodes.len());
|
||||
for (node, environment) in cluster.nodes.iter().enumerate() {
|
||||
let response = tokio::time::timeout_at(deadline, async {
|
||||
let response = http
|
||||
.get(format!("{}/health/ready", environment.url))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status().as_u16();
|
||||
let payload = response.json::<serde_json::Value>().await?;
|
||||
Ok::<_, reqwest::Error>((status, payload))
|
||||
})
|
||||
.await;
|
||||
let observation = match response {
|
||||
Ok(Ok((status, payload))) => {
|
||||
let node_ready = pool_metadata_node_health_is_ready(status, &payload);
|
||||
ready &= node_ready;
|
||||
serde_json::json!({"node": node, "status": status, "ready": node_ready, "payload": payload})
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
ready = false;
|
||||
serde_json::json!({"node": node, "error": error.to_string()})
|
||||
}
|
||||
Err(_) => {
|
||||
ready = false;
|
||||
serde_json::json!({"node": node, "error": "pool metadata health deadline elapsed"})
|
||||
}
|
||||
};
|
||||
observations.push(observation);
|
||||
}
|
||||
let deadline_elapsed = Instant::now() >= deadline;
|
||||
(
|
||||
ready && !deadline_elapsed,
|
||||
serde_json::json!({"deadline_elapsed": deadline_elapsed, "nodes": observations}),
|
||||
)
|
||||
}
|
||||
|
||||
fn pool_metadata_manifest_fixture() -> VersionShardCensus {
|
||||
VersionShardCensus {
|
||||
version_id: None,
|
||||
has_xl_meta: true,
|
||||
data_dir: Some(uuid::Uuid::from_u128(1).to_string()),
|
||||
erasure_index: Some(1),
|
||||
erasure_distribution: Some(vec![1, 2, 3, 4]),
|
||||
data_blocks: Some(2),
|
||||
parity_blocks: Some(2),
|
||||
expected_part_numbers: Default::default(),
|
||||
present_part_fingerprints: Default::default(),
|
||||
inline_data_fingerprint: Some(crate::chaos::PartShardFingerprint {
|
||||
size: 7,
|
||||
sha256: sha256_hex(b"payload"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_republication_allows_only_valid_data_dir_change() {
|
||||
let expected = pool_metadata_manifest_fixture();
|
||||
let mut actual = expected.clone();
|
||||
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
|
||||
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
|
||||
assert!(
|
||||
!actual.matches_manifest(&expected),
|
||||
"ordinary object manifests must retain their original identity"
|
||||
);
|
||||
for invalid in [
|
||||
None,
|
||||
Some(String::new()),
|
||||
Some("invalid".to_string()),
|
||||
Some(uuid::Uuid::nil().to_string()),
|
||||
] {
|
||||
let mut changed = actual.clone();
|
||||
changed.data_dir = invalid.clone();
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed_expected = expected.clone();
|
||||
changed_expected.data_dir = invalid;
|
||||
assert!(!pool_metadata_matches_republished_manifest(&actual, &changed_expected));
|
||||
}
|
||||
let mut changed = actual.clone();
|
||||
changed
|
||||
.inline_data_fingerprint
|
||||
.as_mut()
|
||||
.expect("fixture has inline payload")
|
||||
.sha256 = sha256_hex(b"corrupt");
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.erasure_index = Some(2);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.erasure_distribution = Some(vec![2, 1, 3, 4]);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.data_blocks = Some(1);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.parity_blocks = Some(3);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.version_id = Some(uuid::Uuid::from_u128(3).to_string());
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual;
|
||||
changed.has_xl_meta = false;
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_republication_requires_exact_complete_part_fingerprints() {
|
||||
let mut expected = pool_metadata_manifest_fixture();
|
||||
expected.inline_data_fingerprint = None;
|
||||
expected.expected_part_numbers.insert(1);
|
||||
expected.present_part_fingerprints.insert(
|
||||
1,
|
||||
crate::chaos::PartShardFingerprint {
|
||||
size: 7,
|
||||
sha256: sha256_hex(b"payload"),
|
||||
},
|
||||
);
|
||||
let mut actual = expected.clone();
|
||||
actual.data_dir = Some(uuid::Uuid::from_u128(2).to_string());
|
||||
assert!(pool_metadata_matches_republished_manifest(&actual, &expected));
|
||||
let mut changed = actual.clone();
|
||||
changed.present_part_fingerprints.remove(&1);
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
let mut changed = actual;
|
||||
changed
|
||||
.present_part_fingerprints
|
||||
.get_mut(&1)
|
||||
.expect("fixture has part 1")
|
||||
.sha256 = sha256_hex(b"corrupt");
|
||||
assert!(!pool_metadata_matches_republished_manifest(&changed, &expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_metadata_node_health_requires_explicit_metadata_and_both_quorums() {
|
||||
let expected = serde_json::json!({"ready": true, "details": {
|
||||
"poolMetadata": {"ready": true},
|
||||
"storage": {"readQuorum": true, "writeQuorum": true}
|
||||
}});
|
||||
assert!(pool_metadata_node_health_is_ready(200, &expected));
|
||||
assert!(!pool_metadata_node_health_is_ready(503, &expected));
|
||||
assert!(!pool_metadata_node_health_is_ready(200, &serde_json::json!({"ready": true})));
|
||||
for path in [
|
||||
"/ready",
|
||||
"/details/poolMetadata/ready",
|
||||
"/details/storage/readQuorum",
|
||||
"/details/storage/writeQuorum",
|
||||
] {
|
||||
for value in [
|
||||
serde_json::Value::Bool(false),
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!("true"),
|
||||
] {
|
||||
let mut changed = expected.clone();
|
||||
*changed.pointer_mut(path).expect("fixture contains required health field") = value;
|
||||
assert!(!pool_metadata_node_health_is_ready(200, &changed), "required health field {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EvidenceBuild {
|
||||
sha256: String,
|
||||
@@ -1206,7 +1396,9 @@ mod tests {
|
||||
let interval = interval
|
||||
.filter(|interval| *interval != 0)
|
||||
.ok_or("scanner schedule has no positive interval")?;
|
||||
let next_cycle = cycle_end
|
||||
// A new leader may already be sleeping before completing its first cycle.
|
||||
let schedule_start = if cycle_end == 0 { now } else { cycle_end };
|
||||
let next_cycle = schedule_start
|
||||
.checked_add(interval)
|
||||
.ok_or("scanner schedule timestamp overflows")?;
|
||||
let budget = next_cycle
|
||||
@@ -1223,6 +1415,11 @@ mod tests {
|
||||
Ok(Duration::from_secs(259)),
|
||||
);
|
||||
assert_eq!(scanner_cycle_completion_budget(100, Some(1), 110, false), Ok(Duration::from_secs(60)));
|
||||
// A newly elected leader can advertise a sleep before its first completed cycle.
|
||||
assert_eq!(
|
||||
scanner_cycle_completion_budget(0, Some(289), 1_792_913_211, false),
|
||||
Ok(Duration::from_secs(349)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1243,7 +1440,7 @@ mod tests {
|
||||
previous_cycle_end: u64,
|
||||
) -> Result<u64, Box<dyn Error + Send + Sync>> {
|
||||
let mut deadline = Instant::now() + Duration::from_secs(60);
|
||||
let mut schedule_accounted = false;
|
||||
let mut accounted_schedules = HashSet::new();
|
||||
loop {
|
||||
let mut latest_cycle_end = 0;
|
||||
let mut versions_observed = false;
|
||||
@@ -1290,15 +1487,27 @@ mod tests {
|
||||
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") {
|
||||
if 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;
|
||||
if active || schedule["effective_interval_available"].as_bool() == Some(true) {
|
||||
let interval = if active {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
schedule["effective_interval_seconds"]
|
||||
.as_u64()
|
||||
.ok_or("scanner schedule is missing its effective interval")?,
|
||||
)
|
||||
};
|
||||
// Reconsider a changed leader or retry plan, but never renew
|
||||
// the deadline on repeated observations of the same sleep.
|
||||
if accounted_schedules.insert((node_index, cycle_end, interval)) {
|
||||
let budget = scanner_cycle_completion_budget(cycle_end, interval, now, active)?;
|
||||
let scheduled_deadline = Instant::now().checked_add(budget).ok_or("scanner deadline overflows")?;
|
||||
deadline = deadline.max(scheduled_deadline);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Superseded walks complete all sets while withholding quota authority.
|
||||
@@ -2213,6 +2422,7 @@ mod tests {
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(180);
|
||||
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
||||
let mut last_pool_metadata_readiness = serde_json::Value::Null;
|
||||
loop {
|
||||
if restart_recovery_admin_after_failure && !recovery_admin_task_restarted {
|
||||
let task_state = timeout(
|
||||
@@ -2254,14 +2464,23 @@ mod tests {
|
||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
||||
let pool_metadata_matches = match &expected_pool_metadata {
|
||||
Some(expected) => {
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
|
||||
.matches_manifest(expected)
|
||||
let actual =
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
pool_metadata_matches_republished_manifest(&actual, expected)
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
let pool_metadata_ready = if expected_pool_metadata.is_some() && pool_metadata_matches {
|
||||
let (ready, observation) = all_nodes_pool_metadata_readiness(&cluster, heal_deadline).await;
|
||||
last_pool_metadata_readiness = observation;
|
||||
ready
|
||||
} else {
|
||||
expected_pool_metadata.is_none()
|
||||
};
|
||||
if matching == expected_manifests.len()
|
||||
&& (!outage_target_manifest_required || outage_census.is_complete())
|
||||
&& pool_metadata_matches
|
||||
&& pool_metadata_ready
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -2271,6 +2490,15 @@ mod tests {
|
||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, &outage_key, None)?;
|
||||
let pool_metadata =
|
||||
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
|
||||
// This post-deadline observation is diagnostic only. It cannot
|
||||
// make an expired recovery attempt satisfy the original budget.
|
||||
let pool_metadata_readiness = if expected_pool_metadata.is_some() {
|
||||
let (_, observation) =
|
||||
all_nodes_pool_metadata_readiness(&cluster, Instant::now() + Duration::from_secs(3)).await;
|
||||
serde_json::json!({"last_eligible_check": last_pool_metadata_readiness, "timeout_check": observation})
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
};
|
||||
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
|
||||
.await
|
||||
.unwrap_or_else(|err| format!("status request failed: {err}"));
|
||||
@@ -2294,7 +2522,7 @@ mod tests {
|
||||
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
|
||||
};
|
||||
return Err(format!(
|
||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata_expected={expected_pool_metadata:?}, pool_metadata={pool_metadata:?}, pool_metadata_readiness={pool_metadata_readiness}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
|
||||
expected_manifests.len()
|
||||
)
|
||||
.into());
|
||||
|
||||
@@ -31,12 +31,6 @@ pub mod chaos;
|
||||
#[cfg(test)]
|
||||
pub mod fake_s3_target;
|
||||
|
||||
// Dual-stack differential (rustfs/backlog#2740): one binary, two processes on
|
||||
// different RUSTFS_S3_STACK values, the same SDK script for every S3 operation,
|
||||
// compared on status, error code, allowlisted headers and typed body fields.
|
||||
#[cfg(test)]
|
||||
mod dual_stack_differential;
|
||||
|
||||
// On-demand migration (backlog#2147): shared two-server environment, admin
|
||||
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
|
||||
// added by later ODM tasks.
|
||||
|
||||
@@ -1129,12 +1129,14 @@ mod tests {
|
||||
/// and the marker plus the cursor tag.
|
||||
///
|
||||
/// Bug Reference: backlog#1047
|
||||
/// The V2 continuation token appends a `[rustfs_cache:...]` cursor tag to the
|
||||
/// last returned key. The orchestration layer passed that raw string to
|
||||
/// `forward_past`, so any key whose byte after the shared stem sorts below
|
||||
/// `[` (0x5B) — `.`, `/`, `-`, digits, uppercase — was silently dropped on
|
||||
/// the next page: with keys `a`, `a.txt`, `zz` and max_keys=1, page 2
|
||||
/// returned `zz` and `a.txt` was never listed.
|
||||
/// Earlier releases appended a `[rustfs_cache:...]` cursor tag to the last
|
||||
/// returned key inside the V2 continuation token. The orchestration layer
|
||||
/// passed that raw string to `forward_past`, so any key whose byte after the
|
||||
/// shared stem sorts below `[` (0x5B) — `.`, `/`, `-`, digits, uppercase —
|
||||
/// was silently dropped on the next page: with keys `a`, `a.txt`, `zz` and
|
||||
/// max_keys=1, page 2 returned `zz` and `a.txt` was never listed. The token
|
||||
/// is now base64 of the plain key, and this test keeps guarding the
|
||||
/// continuation boundary.
|
||||
#[tokio::test]
|
||||
async fn test_list_objects_v2_continuation_keeps_keys_after_marker_stem() {
|
||||
init_logging();
|
||||
|
||||
@@ -32,9 +32,7 @@
|
||||
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-3p3x-734c-h5vx>
|
||||
|
||||
use crate::common::rustfs_binary_path_with_features;
|
||||
use crate::common::{
|
||||
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, signed_s3_request,
|
||||
};
|
||||
use crate::common::{AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via};
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, admin_request};
|
||||
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
|
||||
use anyhow::Result;
|
||||
@@ -776,81 +774,6 @@ async fn test_webdav_core_operations_direct() -> Result<()> {
|
||||
test_webdav_core_operations().await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webdav_content_type_directory_marker() -> Result<()> {
|
||||
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
let admin_base_url = format!("http://{}", S3_TEST_ADDRESS);
|
||||
let binary_path = rustfs_binary_path_with_features(Some("webdav"));
|
||||
let mut server_process = Command::new(&binary_path)
|
||||
.arg("--address")
|
||||
.arg(S3_TEST_ADDRESS)
|
||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||
.env("RUSTFS_WEBDAV_ENABLE", "true")
|
||||
.env("RUSTFS_WEBDAV_ADDRESS", WEBDAV_ADDRESS)
|
||||
.env("RUSTFS_WEBDAV_TLS_ENABLED", "false")
|
||||
.arg(&env.temp_dir)
|
||||
.spawn()?;
|
||||
|
||||
let result = async {
|
||||
ProtocolTestEnvironment::wait_for_port_ready(WEBDAV_PORT, 30)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let client = create_client();
|
||||
let bucket_name = "webdav-content-type-bucket";
|
||||
let directory_name = "content-type-directory";
|
||||
let webdav_base_url = format!("http://{}", WEBDAV_ADDRESS);
|
||||
let mkcol_bucket = client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"),
|
||||
format!("{}/{}", webdav_base_url, bucket_name),
|
||||
)
|
||||
.header("Authorization", basic_auth_header())
|
||||
.send()
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
mkcol_bucket.status().is_success() || mkcol_bucket.status().as_u16() == 201,
|
||||
"MKCOL bucket should succeed, got: {}",
|
||||
mkcol_bucket.status()
|
||||
);
|
||||
|
||||
let mkcol_directory = client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"),
|
||||
format!("{}/{}/{}", webdav_base_url, bucket_name, directory_name),
|
||||
)
|
||||
.header("Authorization", basic_auth_header())
|
||||
.send()
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
mkcol_directory.status().is_success() || mkcol_directory.status().as_u16() == 201,
|
||||
"MKCOL directory should succeed, got: {}",
|
||||
mkcol_directory.status()
|
||||
);
|
||||
|
||||
let head_url = format!("{}/{}/{}/", admin_base_url, bucket_name, directory_name);
|
||||
let head = signed_s3_request(http::Method::HEAD, &head_url, None, None, DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
anyhow::ensure!(head.status().is_success(), "HEAD directory marker should succeed, got: {}", head.status());
|
||||
let content_type = head
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
anyhow::ensure!(
|
||||
content_type == "application/x-directory",
|
||||
"WebDAV directory marker must preserve its content type, got: {content_type}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = server_process.kill().await;
|
||||
let _ = server_process.wait().await;
|
||||
result
|
||||
}
|
||||
|
||||
/// Extract the numeric value of the first occurrence of a DAV property in a
|
||||
/// PROPFIND multistatus body. Namespace-prefix agnostic; a self-closing
|
||||
/// (unreported) property yields None.
|
||||
|
||||
@@ -19,6 +19,19 @@ use http::{Method, StatusCode};
|
||||
use tokio::time::{Duration, sleep, timeout};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// A successful reporting response can still have unknown scanner usage.
|
||||
fn reported_quota_usage(response: &serde_json::Value) -> std::io::Result<Option<u64>> {
|
||||
let value = response
|
||||
.get("current_usage")
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "quota response is missing current_usage"))?;
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.as_u64().map(Some).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "quota current_usage is not an unsigned integer or null")
|
||||
})
|
||||
}
|
||||
|
||||
/// Test environment setup for quota tests
|
||||
pub struct QuotaTestEnv {
|
||||
pub env: RustFSTestEnvironment,
|
||||
@@ -87,35 +100,69 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bucket_quota_stats(&self) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, self.bucket_name);
|
||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to get quota stats: {}", response).into())
|
||||
} else {
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
async fn wait_for_usage_response(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<String>,
|
||||
expected_usage: u64,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let convergence = async {
|
||||
loop {
|
||||
let (status, response) = admin_request(
|
||||
&self.env.url,
|
||||
method.clone(),
|
||||
path,
|
||||
body.clone(),
|
||||
&self.env.access_key,
|
||||
&self.env.secret_key,
|
||||
)
|
||||
.await?;
|
||||
if status.is_success() {
|
||||
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||
if let Some(usage) = reported_quota_usage(&response)?
|
||||
&& usage == expected_usage
|
||||
{
|
||||
return Ok::<_, Box<dyn std::error::Error + Send + Sync>>(response);
|
||||
}
|
||||
} else if status != StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Err(format!("quota usage request failed for {path}: {status} {response}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(30), convergence).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("quota usage did not converge to {expected_usage} for {path} within 30 seconds").into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_bucket_quota(
|
||||
async fn wait_for_bucket_quota_stats(
|
||||
&self,
|
||||
bucket: &str,
|
||||
expected_usage: u64,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.wait_for_usage_response(Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None, expected_usage)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_bucket_quota_check(
|
||||
&self,
|
||||
operation_type: &str,
|
||||
operation_size: u64,
|
||||
expected_usage: u64,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("{}/rustfs/admin/v3/quota-check/{}", self.env.url, self.bucket_name);
|
||||
let check_request = serde_json::json!({
|
||||
"operation_type": operation_type,
|
||||
"operation_size": operation_size
|
||||
});
|
||||
|
||||
let response = awscurl_post(&url, &check_request.to_string(), &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to check quota: {}", response).into())
|
||||
} else {
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
}
|
||||
self.wait_for_usage_response(
|
||||
Method::POST,
|
||||
&format!("/rustfs/admin/v3/quota-check/{}", self.bucket_name),
|
||||
Some(check_request.to_string()),
|
||||
expected_usage,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upload_object(&self, key: &str, size_bytes: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -145,25 +192,9 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bucket_usage(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let stats = self.get_bucket_quota_stats().await?;
|
||||
Ok(stats.get("current_usage").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn wait_for_bucket_usage(&self, expected: u64) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let convergence = async {
|
||||
loop {
|
||||
let usage = self.get_bucket_usage().await?;
|
||||
if usage == expected {
|
||||
return Ok::<u64, Box<dyn std::error::Error + Send + Sync>>(usage);
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(30), convergence).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("bucket usage did not converge to {expected} bytes within 30 seconds").into()),
|
||||
}
|
||||
let stats = self.wait_for_bucket_quota_stats(&self.bucket_name, expected).await?;
|
||||
reported_quota_usage(&stats)?.ok_or_else(|| "converged quota usage is missing".into())
|
||||
}
|
||||
|
||||
pub async fn set_bucket_quota_for(
|
||||
@@ -171,8 +202,6 @@ impl QuotaTestEnv {
|
||||
bucket: &str,
|
||||
quota_bytes: u64,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.wait_for_quota_usage_for(bucket).await?;
|
||||
|
||||
let quota_path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": quota_bytes,
|
||||
@@ -206,46 +235,6 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_quota_usage_for(&self, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let stats_path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
|
||||
let readiness = async {
|
||||
loop {
|
||||
let (status, response) =
|
||||
admin_request(&self.env.url, Method::GET, &stats_path, None, &self.env.access_key, &self.env.secret_key)
|
||||
.await?;
|
||||
if status.is_success() {
|
||||
return Ok::<(), Box<dyn std::error::Error + Send + Sync>>(());
|
||||
}
|
||||
if status != StatusCode::SERVICE_UNAVAILABLE {
|
||||
return Err(format!("quota usage readiness failed for {bucket}: {status} {response}").into());
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
};
|
||||
match timeout(Duration::from_secs(30), readiness).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("quota usage did not become authoritative for {bucket} within 30 seconds").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get bucket quota statistics for specific bucket
|
||||
pub async fn get_bucket_quota_stats_for(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
debug!("Getting quota stats for bucket: {}", bucket);
|
||||
|
||||
let url = format!("{}/rustfs/admin/v3/quota-stats/{}", self.env.url, bucket);
|
||||
let response = awscurl_get(&url, &self.env.access_key, &self.env.secret_key).await?;
|
||||
|
||||
if response.contains("error") {
|
||||
Err(format!("Failed to get quota stats: {}", response).into())
|
||||
} else {
|
||||
let stats: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload an object to specific bucket
|
||||
pub async fn upload_object_to_bucket(
|
||||
&self,
|
||||
@@ -270,6 +259,132 @@ impl QuotaTestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod usage_response_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[test]
|
||||
fn unknown_usage_is_not_zero() {
|
||||
assert_eq!(
|
||||
reported_quota_usage(&serde_json::json!({"current_usage": null})).expect("valid unknown usage"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
reported_quota_usage(&serde_json::json!({"current_usage": 0})).expect("known zero usage"),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_usage_is_not_readiness() {
|
||||
for response in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"current_usage": -1}),
|
||||
serde_json::json!({"current_usage": "0"}),
|
||||
serde_json::json!({"current_usage": 1.5}),
|
||||
serde_json::json!({"current_usage": false}),
|
||||
] {
|
||||
let error = reported_quota_usage(&response).expect_err("malformed usage must fail");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
|
||||
async fn mock_environment(
|
||||
responses: Vec<(StatusCode, &'static str)>,
|
||||
) -> Result<(QuotaTestEnv, JoinSet<()>), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let address = listener.local_addr()?;
|
||||
let mut tasks = JoinSet::new();
|
||||
tasks.spawn(async move {
|
||||
for (status, body) in responses {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept quota reporting request");
|
||||
let mut request = Vec::new();
|
||||
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
|
||||
let mut buffer = [0_u8; 1024];
|
||||
let read = stream.read(&mut buffer).await.expect("read reporting request");
|
||||
assert!(read > 0 && request.len() + read <= 8192, "bounded HTTP request headers");
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
let response = format!(
|
||||
"HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
status.as_u16(),
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("write quota reporting response");
|
||||
stream.shutdown().await.expect("close reporting response");
|
||||
}
|
||||
});
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.url = format!("http://{address}");
|
||||
let client = env.create_s3_client();
|
||||
Ok((
|
||||
QuotaTestEnv {
|
||||
env,
|
||||
client,
|
||||
bucket_name: "quota-reporting-fixture".to_owned(),
|
||||
},
|
||||
tasks,
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reporting_waits_for_known_matching_usage_without_selecting_allowed()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (env, mut tasks) = mock_environment(vec![
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
|
||||
(StatusCode::OK, r#"{"current_usage":null}"#),
|
||||
(StatusCode::OK, r#"{"current_usage":1}"#),
|
||||
(StatusCode::OK, r#"{"current_usage":0,"allowed":false}"#),
|
||||
])
|
||||
.await?;
|
||||
let response = env.wait_for_usage_response(Method::GET, "/reporting", None, 0).await?;
|
||||
assert_eq!(response["current_usage"], 0);
|
||||
assert_eq!(response["allowed"], false, "readiness must not select a desired admission outcome");
|
||||
timeout(Duration::from_secs(1), tasks.join_next())
|
||||
.await?
|
||||
.expect("reporting mock task")
|
||||
.expect("all responses consumed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reporting_rejects_malformed_success_instead_of_retrying() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (env, mut tasks) = mock_environment(vec![(StatusCode::OK, r#"{"current_usage":"unknown"}"#)]).await?;
|
||||
let error = env
|
||||
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
|
||||
.await
|
||||
.expect_err("malformed usage must fail");
|
||||
assert!(error.to_string().contains("not an unsigned integer or null"));
|
||||
timeout(Duration::from_secs(1), tasks.join_next())
|
||||
.await?
|
||||
.expect("reporting mock task")
|
||||
.expect("response consumed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reporting_does_not_retry_authorization_failure() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (env, mut tasks) = mock_environment(vec![(StatusCode::FORBIDDEN, "denied")]).await?;
|
||||
let error = env
|
||||
.wait_for_usage_response(Method::GET, "/reporting", None, 0)
|
||||
.await
|
||||
.expect_err("authorization error must fail");
|
||||
assert!(error.to_string().contains("403"));
|
||||
timeout(Duration::from_secs(1), tasks.join_next())
|
||||
.await?
|
||||
.expect("reporting mock task")
|
||||
.expect("response consumed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
@@ -482,7 +597,7 @@ mod integration_tests {
|
||||
env.upload_object("file2.txt", 256 * 1024).await?;
|
||||
|
||||
// Check usage
|
||||
let usage = env.get_bucket_usage().await?;
|
||||
let usage = env.wait_for_bucket_usage((512 + 256) * 1024).await?;
|
||||
assert_eq!(usage, (512 + 256) * 1024);
|
||||
|
||||
// Delete a file
|
||||
@@ -517,7 +632,7 @@ mod integration_tests {
|
||||
env.upload_object("file2.txt", 512 * 1024).await?;
|
||||
|
||||
// Get detailed quota statistics
|
||||
let stats = env.get_bucket_quota_stats().await?;
|
||||
let stats = env.wait_for_bucket_quota_stats(&env.bucket_name, (1024 + 512) * 1024).await?;
|
||||
|
||||
assert_eq!(stats.get("bucket").unwrap().as_str().unwrap(), env.bucket_name);
|
||||
assert_eq!(stats.get("quota_limit").unwrap().as_u64().unwrap(), 2 * 1024 * 1024);
|
||||
@@ -546,22 +661,22 @@ mod integration_tests {
|
||||
env.upload_object("existing_file.txt", 512 * 1024).await?;
|
||||
|
||||
// Check if we can upload another 512KB (should succeed, exactly fill quota)
|
||||
let check_result = env.check_bucket_quota("PUT", 512 * 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("PUT", 512 * 1024, 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 0);
|
||||
|
||||
// Note: we haven't actually uploaded the second file yet, so current_usage is still 512KB
|
||||
// Check if we can upload 1KB (should succeed - we haven't used the full quota yet)
|
||||
let check_result = env.check_bucket_quota("PUT", 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("PUT", 1024, 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
assert_eq!(check_result.get("remaining_quota").unwrap().as_u64().unwrap(), 523264); // 511 * 1024
|
||||
|
||||
// Check if we can upload 600KB (should fail - would exceed quota)
|
||||
let check_result = env.check_bucket_quota("PUT", 600 * 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("PUT", 600 * 1024, 512 * 1024).await?;
|
||||
assert!(!check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
|
||||
// Check delete operation (should always be allowed)
|
||||
let check_result = env.check_bucket_quota("DELETE", 512 * 1024).await?;
|
||||
let check_result = env.wait_for_bucket_quota_check("DELETE", 512 * 1024, 512 * 1024).await?;
|
||||
assert!(check_result.get("allowed").unwrap().as_bool().unwrap());
|
||||
|
||||
env.cleanup_bucket().await?;
|
||||
@@ -593,8 +708,8 @@ mod integration_tests {
|
||||
env.upload_object_to_bucket(&bucket2, "another_file.txt", 512 * 1024).await?;
|
||||
|
||||
// Verify statistics are independent
|
||||
let stats1 = env.get_bucket_quota_stats_for(&bucket1).await?;
|
||||
let stats2 = env.get_bucket_quota_stats_for(&bucket2).await?;
|
||||
let stats1 = env.wait_for_bucket_quota_stats(&bucket1, 1024 * 1024).await?;
|
||||
let stats2 = env.wait_for_bucket_quota_stats(&bucket2, (1024 + 512) * 1024).await?;
|
||||
|
||||
assert_eq!(stats1.get("current_usage").unwrap().as_u64().unwrap(), 1024 * 1024);
|
||||
assert_eq!(stats2.get("current_usage").unwrap().as_u64().unwrap(), (1024 + 512) * 1024);
|
||||
@@ -655,7 +770,6 @@ mod integration_tests {
|
||||
let env = QuotaTestEnv::new().await?;
|
||||
|
||||
env.create_bucket().await?;
|
||||
env.wait_for_quota_usage_for(&env.bucket_name).await?;
|
||||
|
||||
// Test 1: GET quota for bucket without quota config
|
||||
let url = format!("{}/rustfs/admin/v3/quota/{}", env.env.url, env.bucket_name);
|
||||
|
||||
@@ -548,6 +548,20 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_multipart_data(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RecoverMultipartSourceResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_data_at_incarnation(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
@@ -562,6 +576,25 @@ impl NodeService for MinimalLockNodeService {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn rename_data_with_pool_meta_cas(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
async fn undo_pool_meta_write(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
async fn pool_meta_native_recovery(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryRequest>,
|
||||
) -> Result<Response<rustfs_protos::proto_gen::node_service::PoolMetaNativeRecoveryResponse>, Status> {
|
||||
Err(Status::unimplemented("lock-only test server"))
|
||||
}
|
||||
|
||||
async fn make_volumes(
|
||||
&self,
|
||||
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,11 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskOption, Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions, new_disk};
|
||||
pub(crate) const OBJECT_LOCK_TRACE_FILTER: &str = "info,rustfs_ecstore::object_lock_diag=trace";
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{
|
||||
DiskOption, Endpoint, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, VolumeInfo, WalkDirOptions, new_disk,
|
||||
};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
@@ -55,5 +59,24 @@ pub(crate) mod replication_extension {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod upgrade_compatibility {
|
||||
pub(crate) use super::{DiskOption, Endpoint, new_disk};
|
||||
pub(crate) use super::{DiskOption, Endpoint, RUSTFS_META_MULTIPART_BUCKET, new_disk};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod multipart_rpc_delay {
|
||||
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod multipart_fence_acceptance {
|
||||
pub(crate) use super::RUSTFS_META_MULTIPART_BUCKET;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod multipart_source_rpc {
|
||||
pub(crate) use rustfs_ecstore::api::disk::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
|
||||
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceSnapshot,
|
||||
PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject,
|
||||
use crate::replication_extension_test::{
|
||||
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
|
||||
};
|
||||
use crate::storage_api::upgrade_compatibility::RUSTFS_META_MULTIPART_BUCKET;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
@@ -52,6 +53,8 @@ const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||
const PLAIN_BUCKET: &str = "upgrade-plain-data";
|
||||
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
||||
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
|
||||
const PENDING_MULTIPART_BUCKET: &str = "upgrade-mpu-continuity";
|
||||
const PENDING_MULTIPART_KEY: &str = "pending/legacy-multipart.bin";
|
||||
const MIXED_NODE_COUNT: usize = 4;
|
||||
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
|
||||
// Written by the "previous-seed" readiness probe through node 0 before any
|
||||
@@ -301,18 +304,27 @@ async fn read_object(
|
||||
Ok((encryption, body))
|
||||
}
|
||||
|
||||
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
||||
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = created.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
|
||||
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||
struct PendingMultipart {
|
||||
upload_id: String,
|
||||
completed_parts: Vec<CompletedPart>,
|
||||
part_sizes: Vec<i64>,
|
||||
}
|
||||
|
||||
async fn stage_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> Result<PendingMultipart, BoxError> {
|
||||
let created = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = created
|
||||
.upload_id()
|
||||
.ok_or("CreateMultipartUpload omitted upload ID")?
|
||||
.to_owned();
|
||||
let mut completed_parts = Vec::with_capacity(parts.len());
|
||||
let mut part_sizes = Vec::with_capacity(parts.len());
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part_number = i32::try_from(index + 1)?;
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(part.clone()))
|
||||
.send()
|
||||
@@ -323,16 +335,38 @@ async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<
|
||||
.e_tag(uploaded.e_tag().ok_or("UploadPart omitted ETag")?)
|
||||
.build(),
|
||||
);
|
||||
part_sizes.push(i64::try_from(part.len())?);
|
||||
}
|
||||
Ok(PendingMultipart {
|
||||
upload_id,
|
||||
completed_parts,
|
||||
part_sizes,
|
||||
})
|
||||
}
|
||||
|
||||
client
|
||||
async fn complete_pending_multipart(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
pending: &PendingMultipart,
|
||||
) -> Result<aws_sdk_s3::operation::complete_multipart_upload::CompleteMultipartUploadOutput, BoxError> {
|
||||
Ok(client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
.upload_id(&pending.upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(pending.completed_parts.clone()))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<u8>]) -> TestResult {
|
||||
let pending = stage_multipart(client, bucket, key, parts).await?;
|
||||
complete_pending_multipart(client, bucket, key, &pending).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -379,6 +413,330 @@ async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<Str
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
async fn write_mixed_put_load(clients: &[Client], phase: &str) -> Result<Vec<String>, BoxError> {
|
||||
let mut tasks = JoinSet::new();
|
||||
for worker in 0..MULTIPART_WORKERS {
|
||||
let node = worker % clients.len();
|
||||
let client = clients[node].clone();
|
||||
let phase = phase.to_owned();
|
||||
tasks.spawn(async move {
|
||||
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
|
||||
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||
let key = format!("{phase}/put/{worker:02}/{upload:02}");
|
||||
tokio::time::timeout(
|
||||
MIXED_MULTIPART_UPLOAD_TIMEOUT,
|
||||
client
|
||||
.put_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.key(&key)
|
||||
.body(ByteStream::from(vec![u8::try_from(worker)?; 64 * 1024]))
|
||||
.send(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("node {node}: PUT {key} acknowledgement deadline exceeded"))??;
|
||||
keys.push(key);
|
||||
}
|
||||
Ok::<_, BoxError>(keys)
|
||||
});
|
||||
}
|
||||
let mut keys = Vec::with_capacity(MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
keys.extend(result??);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
async fn verify_load_bodies(clients: &[Client], phase: &str, operation: &str) -> TestResult {
|
||||
for worker in 0..MULTIPART_WORKERS {
|
||||
let reader = &clients[(worker + 1) % clients.len()];
|
||||
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||
let key = format!("{phase}/{operation}/{worker:02}/{upload:02}");
|
||||
assert_eq!(
|
||||
read_object(reader, MIXED_BUCKET, &key, None).await?.1,
|
||||
vec![u8::try_from(worker)?; 64 * 1024],
|
||||
"{phase}: acknowledged {key} body"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exercise_homogeneous_multipart(cluster: &RustFSTestClusterEnvironment, phase: &str) -> TestResult {
|
||||
let clients = cluster.create_all_clients()?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_drive_readmission(&clients, MIXED_BUCKET, phase, DRIVE_READMISSION_TIMEOUT).await?;
|
||||
let keys = match write_multipart_load(&clients, phase).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => {
|
||||
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
|
||||
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
|
||||
}
|
||||
};
|
||||
assert_eq!(keys.len(), MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
|
||||
verify_load_bodies(&clients, phase, "multipart").await?;
|
||||
for (reader, client) in clients.iter().enumerate() {
|
||||
wait_for_phase_listing(client, phase, keys.len(), &format!("homogeneous reader {reader}")).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct PendingSourceBytes {
|
||||
root: PathBuf,
|
||||
files: Vec<(PathBuf, Vec<u8>)>,
|
||||
}
|
||||
|
||||
fn pending_source_bytes(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
pending: &PendingMultipart,
|
||||
) -> Result<Vec<PendingSourceBytes>, BoxError> {
|
||||
let source_hash =
|
||||
rustfs_utils::hex_sha256(format!("{PENDING_MULTIPART_BUCKET}/{PENDING_MULTIPART_KEY}").as_bytes(), str::to_owned);
|
||||
let mut sources = Vec::new();
|
||||
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
let hash_root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET).join(&source_hash);
|
||||
let roots = std::fs::read_dir(&hash_root)?
|
||||
.map(|entry| {
|
||||
let entry = entry?;
|
||||
Ok(entry.file_type()?.is_dir().then(|| entry.path()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if roots.is_empty() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "pending upload source has not settled").into());
|
||||
}
|
||||
assert_eq!(roots.len(), 1, "the dedicated key has exactly one real pending upload");
|
||||
let root = roots.into_iter().next().ok_or("pending upload source missing")?;
|
||||
let mut files = vec![(PathBuf::from("xl.meta"), std::fs::read(root.join("xl.meta"))?)];
|
||||
let data_dirs = std::fs::read_dir(&root)?
|
||||
.map(|entry| {
|
||||
let entry = entry?;
|
||||
Ok((entry.file_type()?.is_dir() && entry.path().join("part.1").is_file()).then(|| entry.path()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, std::io::Error>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
if data_dirs.is_empty() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "selected part publication has not settled").into());
|
||||
}
|
||||
assert_eq!(data_dirs.len(), 1, "the upload has one actual selected-part directory");
|
||||
let data_dir = data_dirs.into_iter().next().ok_or("pending upload parts missing")?;
|
||||
for part in &pending.completed_parts {
|
||||
let number = part.part_number().ok_or("acknowledged part omitted its number")?;
|
||||
for name in [format!("part.{number}"), format!("part.{number}.meta")] {
|
||||
let path = data_dir.join(name);
|
||||
files.push((path.strip_prefix(&root)?.to_owned(), std::fs::read(path)?));
|
||||
}
|
||||
}
|
||||
sources.push(PendingSourceBytes { root, files });
|
||||
}
|
||||
assert_eq!(
|
||||
sources.len(),
|
||||
MIXED_NODE_COUNT,
|
||||
"the controlled legacy fixture requires every physical source"
|
||||
);
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
async fn wait_for_pending_source_bytes(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
pending: &PendingMultipart,
|
||||
) -> Result<Vec<PendingSourceBytes>, BoxError> {
|
||||
let deadline = Instant::now() + DRIVE_READMISSION_TIMEOUT;
|
||||
loop {
|
||||
match pending_source_bytes(cluster, pending) {
|
||||
Ok(sources) => return Ok(sources),
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound)
|
||||
&& Instant::now() < deadline =>
|
||||
{
|
||||
sleep(Duration::from_millis(500)).await
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_pending_parts(client: &Client, pending: &PendingMultipart) -> TestResult {
|
||||
let listed = client
|
||||
.list_parts()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.upload_id(&pending.upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(listed.upload_id(), Some(pending.upload_id.as_str()));
|
||||
assert_ne!(listed.is_truncated(), Some(true));
|
||||
assert_eq!(listed.parts().len(), pending.completed_parts.len());
|
||||
for ((actual, expected), expected_size) in listed.parts().iter().zip(&pending.completed_parts).zip(&pending.part_sizes) {
|
||||
assert_eq!(actual.part_number(), expected.part_number());
|
||||
assert_eq!(actual.e_tag(), expected.e_tag());
|
||||
assert_eq!(actual.size(), Some(*expected_size));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_mixed_pending_refusal(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
pending: &PendingMultipart,
|
||||
sources: &[PendingSourceBytes],
|
||||
) -> TestResult {
|
||||
let clients = cluster.create_all_clients()?;
|
||||
assert_pending_parts(&clients[0], pending).await?;
|
||||
let error = upgrade_probe_client(&clients[0])
|
||||
.complete_multipart_upload()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.upload_id(&pending.upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.set_parts(Some(pending.completed_parts.clone()))
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("one native participant cannot authorize a legacy MPU completion");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503), "{error:?}");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("ServiceUnavailable"));
|
||||
for client in &clients {
|
||||
assert_pending_parts(client, pending).await?;
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("the rejected Complete must not publish a target");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
|
||||
}
|
||||
for source in sources {
|
||||
for entry in std::fs::read_dir(&source.root)? {
|
||||
assert!(
|
||||
!entry?.file_name().to_string_lossy().starts_with(".mpu-"),
|
||||
"the failed preflight must not upgrade one physical legacy source"
|
||||
);
|
||||
}
|
||||
for (relative, expected) in &source.files {
|
||||
let actual = std::fs::read(source.root.join(relative))?;
|
||||
assert!(actual == *expected, "rejected Complete changed actual source {}", relative.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CompletedPendingMultipart {
|
||||
version_id: String,
|
||||
etag: String,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
async fn assert_resumed_multipart(clients: &[Client], completed: &CompletedPendingMultipart) -> TestResult {
|
||||
for (node, client) in clients.iter().enumerate() {
|
||||
let response = client
|
||||
.get_object()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.key(PENDING_MULTIPART_KEY)
|
||||
.version_id(&completed.version_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(response.version_id(), Some(completed.version_id.as_str()), "reader {node}");
|
||||
assert_eq!(response.e_tag(), Some(completed.etag.as_str()), "reader {node}");
|
||||
assert_eq!(
|
||||
response.body.collect().await?.into_bytes().as_ref(),
|
||||
completed.body.as_slice(),
|
||||
"reader {node}"
|
||||
);
|
||||
assert_eq!(
|
||||
read_object(client, PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, None)
|
||||
.await?
|
||||
.1,
|
||||
completed.body
|
||||
);
|
||||
let versions = client
|
||||
.list_object_versions()
|
||||
.bucket(PENDING_MULTIPART_BUCKET)
|
||||
.prefix(PENDING_MULTIPART_KEY)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(versions.versions().len(), 1, "same-upload replay must not add a version");
|
||||
assert_eq!(versions.versions()[0].version_id(), Some(completed.version_id.as_str()));
|
||||
assert!(versions.delete_markers().is_empty());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_pending_multipart(
|
||||
clients: &[Client],
|
||||
pending: &PendingMultipart,
|
||||
body: Vec<u8>,
|
||||
) -> Result<CompletedPendingMultipart, BoxError> {
|
||||
let complete = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
|
||||
let version_id = complete
|
||||
.version_id()
|
||||
.filter(|version| !version.is_empty() && *version != "null")
|
||||
.ok_or("versioned legacy upload completion omitted a concrete version")?
|
||||
.to_owned();
|
||||
let etag = complete.e_tag().ok_or("CompleteMultipartUpload omitted ETag")?.to_owned();
|
||||
let replay = complete_pending_multipart(&clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, pending).await?;
|
||||
assert_eq!(replay.version_id(), Some(version_id.as_str()));
|
||||
assert_eq!(replay.e_tag(), Some(etag.as_str()));
|
||||
let completed = CompletedPendingMultipart { version_id, etag, body };
|
||||
assert_resumed_multipart(clients, &completed).await?;
|
||||
Ok(completed)
|
||||
}
|
||||
|
||||
async fn wait_for_multipart_quiescence(cluster: &RustFSTestClusterEnvironment, sources: &[PendingSourceBytes]) -> TestResult {
|
||||
let deadline = Instant::now() + LISTING_CONVERGENCE_TIMEOUT;
|
||||
loop {
|
||||
let pending_roots = sources.iter().filter(|source| source.root.exists()).count();
|
||||
let mut native_controls = 0;
|
||||
let mut remaining_upload_roots = 0;
|
||||
for volume in cluster.nodes.iter().flat_map(|node| &node.data_dirs) {
|
||||
let root = Path::new(volume).join(RUSTFS_META_MULTIPART_BUCKET);
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in walkdir::WalkDir::new(root) {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error)
|
||||
if error
|
||||
.io_error()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
|
||||
{
|
||||
native_controls += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
// Every fixture upload lives at MPI/<SHA>/<upload>. A born-only
|
||||
// source can contain just xl.meta and its data directory, with
|
||||
// no .mpu-* record. Only empty SHA parents may survive rollback.
|
||||
if entry.depth() == 2 || (entry.depth() == 1 && !entry.file_type().is_dir()) {
|
||||
remaining_upload_roots += 1;
|
||||
}
|
||||
if entry.file_name().to_string_lossy().starts_with(".mpu-") {
|
||||
native_controls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if pending_roots == 0 && remaining_upload_roots == 0 && native_controls == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(format!(
|
||||
"downgrade requires settled native MPU state: {pending_roots} fixed pending roots, {remaining_upload_roots} inventoried upload roots, {native_controls} native controls"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn mixed_multipart_before(
|
||||
deadline: Instant,
|
||||
node: usize,
|
||||
@@ -625,14 +983,11 @@ async fn exercise_mixed_cluster(
|
||||
.await?;
|
||||
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
|
||||
|
||||
let multipart_keys = match write_multipart_load(&clients, phase).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => {
|
||||
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
|
||||
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
|
||||
}
|
||||
};
|
||||
let expected_count = multipart_keys.len() + 2;
|
||||
// Native MPU mutations are operationally paused while RPC generations
|
||||
// differ. Keep the same mixed traffic volume with ordinary PUT instead.
|
||||
let written_keys = write_mixed_put_load(&clients, phase).await?;
|
||||
let expected_count = written_keys.len() + 2;
|
||||
verify_load_bodies(&clients, phase, "put").await?;
|
||||
for (label, client) in [("current", current_client), ("previous", previous_client)] {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
@@ -648,9 +1003,9 @@ async fn exercise_mixed_cluster(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||
let last_put_key = format!("{phase}/put/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
|
||||
assert_eq!(
|
||||
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
|
||||
read_object(previous_client, MIXED_BUCKET, &last_put_key, None).await?.1,
|
||||
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
|
||||
);
|
||||
|
||||
@@ -790,12 +1145,20 @@ fn previous_baseline_writers(clients: &[Client], restarted_node: usize) -> &[Cli
|
||||
}
|
||||
|
||||
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
|
||||
prepare_release_baseline(cluster, previous_binary, "previous").await
|
||||
}
|
||||
|
||||
async fn prepare_release_baseline(
|
||||
cluster: &mut RustFSTestClusterEnvironment,
|
||||
previous_binary: &Path,
|
||||
prefix: &str,
|
||||
) -> TestResult {
|
||||
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
|
||||
// rc.5 can latch a non-elected node's write fence while its first startup
|
||||
// waits for pool metadata (#7473). First prove the elected writer can
|
||||
// persist data, then restart each old process once with three peers still
|
||||
// readable. This preparation ends before any current binary is started.
|
||||
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
// readable. The same preparation is reused only after a quiescent homogeneous rollback.
|
||||
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, &format!("{prefix}-seed"), LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
// A node that just came back is still replaying its startup traffic against
|
||||
// every peer. Killing the next node during that burst gives the fresh node
|
||||
// two failures in a row and it drops that peer's drive to Offline, where
|
||||
@@ -805,7 +1168,7 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
|
||||
for node in [1, 2, 3, 0] {
|
||||
cluster.stop_node(node)?;
|
||||
cluster.start_node_from_binary(node, previous_binary).await?;
|
||||
let context = format!("previous-restart-{node}");
|
||||
let context = format!("{prefix}-restart-{node}");
|
||||
wait_for_upgrade_write_readiness(
|
||||
std::slice::from_ref(&clients[node]),
|
||||
MIXED_BUCKET,
|
||||
@@ -822,15 +1185,17 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
|
||||
.await?;
|
||||
}
|
||||
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, &format!("{prefix}-baseline"), LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
for (reader, client) in clients.iter().enumerate() {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
read_object(client, MIXED_BUCKET, &format!(".upgrade-readiness/{prefix}-seed/node-0"), None)
|
||||
.await?
|
||||
.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
"previous-release node {reader} must retain the seed across its rolling restart"
|
||||
);
|
||||
for writer in 0..clients.len() {
|
||||
let key = format!(".upgrade-readiness/previous-baseline/node-{writer}");
|
||||
let key = format!(".upgrade-readiness/{prefix}-baseline/node-{writer}");
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, &key, None).await?.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
@@ -1883,10 +2248,21 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
cluster.start_with_binary(&previous_binary).await?;
|
||||
cluster.create_test_bucket(MIXED_BUCKET).await?;
|
||||
prepare_previous_release_baseline(&mut cluster, &previous_binary).await?;
|
||||
exercise_homogeneous_multipart(&cluster, "homogeneous-previous").await?;
|
||||
cluster.create_test_bucket(PENDING_MULTIPART_BUCKET).await?;
|
||||
let previous_clients = cluster.create_all_clients()?;
|
||||
enable_versioning(&previous_clients[0], PENDING_MULTIPART_BUCKET).await?;
|
||||
let pending_parts = vec![vec![0x63; 5 * 1024 * 1024], vec![0x7d; 4096]];
|
||||
let pending = stage_multipart(&previous_clients[0], PENDING_MULTIPART_BUCKET, PENDING_MULTIPART_KEY, &pending_parts).await?;
|
||||
let sources = wait_for_pending_source_bytes(&cluster, &pending).await?;
|
||||
assert_pending_parts(&previous_clients[0], &pending).await?;
|
||||
|
||||
cluster.stop_node(0)?;
|
||||
cluster.start_node_from_binary(0, ¤t_binary).await?;
|
||||
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
|
||||
// One controlled out-of-policy attempt proves fail-closed behavior below
|
||||
// native write quorum; positive MPU writers remain paused while mixed.
|
||||
assert_mixed_pending_refusal(&cluster, &pending, &sources).await?;
|
||||
|
||||
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
|
||||
cluster.stop_node(node_idx)?;
|
||||
@@ -1898,6 +2274,9 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
cluster.start_node_from_binary(3, ¤t_binary).await?;
|
||||
let clients = cluster.create_all_clients()?;
|
||||
wait_for_drive_readmission(&clients, MIXED_BUCKET, "homogeneous-current", DRIVE_READMISSION_TIMEOUT).await?;
|
||||
let completed_pending = finish_pending_multipart(&clients, &pending, pending_parts.concat()).await?;
|
||||
exercise_homogeneous_multipart(&cluster, "homogeneous-current").await?;
|
||||
verify_load_bodies(&clients, "homogeneous-previous", "multipart").await?;
|
||||
|
||||
for (node_idx, client) in clients.iter().enumerate() {
|
||||
assert_eq!(
|
||||
@@ -1916,47 +2295,6 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(context) = evidence_context.as_ref() {
|
||||
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
|
||||
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_reader_evidence",
|
||||
"mixed-version-reader",
|
||||
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"assertions": [
|
||||
"current node reads objects written through previous-release client",
|
||||
"previous-release node reads objects written through current client",
|
||||
"all nodes list every mixed-version object after homogeneous-current convergence",
|
||||
"the previous-release seed survives the baseline restart and every upgrade phase"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_writer_evidence",
|
||||
"mixed-version-writer",
|
||||
&["old-reader-new-writer", "new-reader-old-writer"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"assertions": [
|
||||
"current writer publishes objects readable by previous-release node",
|
||||
"previous-release writer publishes objects readable by current node",
|
||||
"multipart writers continue at every individual node upgrade",
|
||||
"modern DELETE fails closed and preserves every byte while old peers remain"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
let clients = cluster.create_all_clients()?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "all-current", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
clients[0]
|
||||
@@ -1980,6 +2318,115 @@ async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
|
||||
);
|
||||
}
|
||||
|
||||
wait_for_multipart_quiescence(&cluster, &sources).await?;
|
||||
// No MPU mutation runs during a heterogeneous downgrade. All current
|
||||
// callbacks and source controls settle before old binaries reopen volumes.
|
||||
cluster.stop();
|
||||
cluster.start_with_binary(&previous_binary).await?;
|
||||
prepare_release_baseline(&mut cluster, &previous_binary, "rollback").await?;
|
||||
let old_clients = cluster.create_all_clients()?;
|
||||
assert_resumed_multipart(&old_clients, &completed_pending).await?;
|
||||
for phase in ["homogeneous-previous", "homogeneous-current"] {
|
||||
verify_load_bodies(&old_clients, phase, "multipart").await?;
|
||||
for client in &old_clients {
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
phase,
|
||||
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||
"previous reader after rollback",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
for phase in ["one-current-node", "two-current-nodes", "one-previous-node"] {
|
||||
verify_load_bodies(&old_clients, phase, "put").await?;
|
||||
for client in &old_clients {
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
phase,
|
||||
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2,
|
||||
"previous reader after rollback",
|
||||
)
|
||||
.await?;
|
||||
for (key, body) in [
|
||||
("written-by-current", format!("{phase}: current RustFS build")),
|
||||
("written-by-previous", format!("{phase}: previous RustFS release")),
|
||||
] {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, &format!("{phase}/{key}"), None).await?.1,
|
||||
body.as_bytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (node, client) in old_clients.iter().enumerate() {
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.key(PREVIOUS_RELEASE_SEED_KEY)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("current DELETE must remain visible to previous readers after rollback");
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||
Some("NoSuchKey"),
|
||||
"reader {node}"
|
||||
);
|
||||
}
|
||||
exercise_homogeneous_multipart(&cluster, "homogeneous-rollback").await?;
|
||||
|
||||
if let Some(context) = evidence_context.as_ref() {
|
||||
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
|
||||
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_reader_evidence",
|
||||
"mixed-version-reader",
|
||||
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"mixed_load": "plain-put",
|
||||
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
|
||||
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||
"assertions": [
|
||||
"current node reads objects written through previous-release client",
|
||||
"previous-release node reads objects written through current client",
|
||||
"all nodes list every mixed-version object after homogeneous-current convergence",
|
||||
"the previous-release seed survives the baseline restart and every upgrade phase",
|
||||
"previous readers read every current-native MPU body after quiescent rollback",
|
||||
"same legacy upload resumes with identical body, ETag and version after full upgrade"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
write_g09_evidence(
|
||||
context,
|
||||
"mixed_version_writer_evidence",
|
||||
"mixed-version-writer",
|
||||
&["old-reader-new-writer", "new-reader-old-writer"],
|
||||
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
|
||||
serde_json::json!({
|
||||
"bucket": MIXED_BUCKET,
|
||||
"phases": phases,
|
||||
"objects_per_phase": objects_per_phase,
|
||||
"mixed_load": "plain-put",
|
||||
"homogeneous_multipart_phases": ["homogeneous-previous", "homogeneous-current", "homogeneous-rollback"],
|
||||
"multipart_objects_per_homogeneous_phase": MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER,
|
||||
"assertions": [
|
||||
"current writer publishes objects readable by previous-release node",
|
||||
"previous-release writer publishes objects readable by current node",
|
||||
"mixed phases retain 16x16 plain PUT writers while positive multipart writers are paused",
|
||||
"16x16 multipart load runs in homogeneous previous, current and rollback phases",
|
||||
"one-current-node Complete returns typed 503 and preserves actual source authority and parts",
|
||||
"the same legacy upload completes and replays once fully upgraded without adding a version",
|
||||
"modern DELETE fails closed and preserves every byte while old peers remain"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,6 @@ workspace = true
|
||||
default = []
|
||||
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
|
||||
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
|
||||
# Compiles the controlled list-objects namespace-journal chaos injector into a
|
||||
# production binary (it is always available to tests). Off by default so the
|
||||
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
|
||||
# state in a stock build (backlog#1832).
|
||||
list-chaos = []
|
||||
rio-v2 = ["dep:rustfs-rio-v2"]
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
@@ -142,7 +137,6 @@ rustfs-lifecycle.workspace = true
|
||||
rustfs-s3-client = { workspace = true }
|
||||
rustfs-s3-types = { workspace = true }
|
||||
rustfs-data-usage.workspace = true
|
||||
rustfs-gateway-types.workspace = true
|
||||
rustfs-object-capacity.workspace = true
|
||||
rustfs-object-data-cache = { workspace = true, features = ["runtime-memory"] }
|
||||
arc-swap.workspace = true
|
||||
@@ -161,6 +155,7 @@ serde = { workspace = true, features = ["derive"] }
|
||||
time = { workspace = true, features = ["parsing", "formatting", "macros", "serde"] }
|
||||
bytesize.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
http.workspace = true
|
||||
opentelemetry.workspace = true
|
||||
http-body = { workspace = true }
|
||||
@@ -249,7 +244,6 @@ windows-sys = { workspace = true, features = [
|
||||
windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
|
||||
|
||||
[dev-dependencies]
|
||||
s3s = { workspace = true, features = ["minio"] }
|
||||
aws-smithy-async.workspace = true
|
||||
rustfs-s3-ops.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
|
||||
|
||||
@@ -62,8 +62,6 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod bucket_lifecycle_ops {
|
||||
pub use rustfs_lifecycle::{RestoreOutputLocation, RestoreRequest, RestoreS3Location, RestoreTag, RestoreTagging};
|
||||
|
||||
pub use crate::bucket::lifecycle::bucket_lifecycle_ops::{
|
||||
ExpiryState, LifecycleOps, ManualTransitionCancelCheck, ManualTransitionProgressSink,
|
||||
ManualTransitionQueueSnapshot, ManualTransitionRunExecution, ManualTransitionRunOptions,
|
||||
@@ -135,7 +133,7 @@ pub mod bucket {
|
||||
pub use crate::bucket::lifecycle::lifecycle::{
|
||||
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate,
|
||||
ObjectOpts, RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time,
|
||||
format_lifecycle_date, object_opts_from_object_info, parse_lifecycle_date,
|
||||
object_opts_from_object_info,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +225,7 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod object_lock {
|
||||
pub use crate::bucket::object_lock::ObjectLockApi;
|
||||
pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt};
|
||||
|
||||
pub mod types {
|
||||
pub use crate::bucket::object_lock::types::{
|
||||
@@ -322,9 +320,9 @@ pub mod bucket {
|
||||
|
||||
pub mod utils {
|
||||
pub use crate::bucket::utils::{
|
||||
BucketConfigXml, BucketConfigXmlError, check_bucket_and_object_names, check_list_objs_args,
|
||||
check_object_name_for_length_and_slash, check_valid_bucket_name_strict, deserialize, has_bad_path_component,
|
||||
is_meta_bucketname, is_valid_object_prefix, serialize,
|
||||
check_bucket_and_object_names, check_list_objs_args, check_object_name_for_length_and_slash,
|
||||
check_valid_bucket_name_strict, deserialize, has_bad_path_component, is_meta_bucketname, is_valid_object_prefix,
|
||||
serialize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,7 +349,7 @@ pub mod capacity {
|
||||
}
|
||||
|
||||
pub mod object_api_utils {
|
||||
pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader};
|
||||
pub use crate::object_api::object_api_utils::{ObjReaderFn, PutObjReader, get_raw_etag, new_getobjectreader, to_s3s_etag};
|
||||
}
|
||||
|
||||
pub mod cluster {
|
||||
@@ -431,6 +429,16 @@ pub mod data_usage {
|
||||
pub mod disk {
|
||||
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
||||
pub use crate::disk::local::{ReplacementExecutionLease, ScanGuard};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::disk::local::{local_durability_modes, set_local_rename_data_fail_after_metadata_commit};
|
||||
pub use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartAbortCertificate, MultipartAbortProof, MultipartAbortProofKind,
|
||||
MultipartAbortRound, MultipartSourceOperation, MultipartSourcePhase, MultipartSourceRequest, MultipartSourceResponse,
|
||||
MultipartSourceSnapshot, PublicationIdentity, SourceGeneration, SourceGeometry, SourceLineage, capture_source_condition,
|
||||
require_source_condition, strip_native_controls,
|
||||
};
|
||||
#[cfg(all(feature = "test-util", unix))]
|
||||
pub use crate::disk::os::set_local_dir_fsync_failure;
|
||||
#[cfg(all(feature = "test-util", not(windows)))]
|
||||
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||
pub use crate::disk::{
|
||||
@@ -440,9 +448,12 @@ pub mod disk {
|
||||
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
|
||||
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
|
||||
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
|
||||
PartTransactionAction, QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp,
|
||||
ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
|
||||
is_quota_mutation_fence_path, new_disk, validate_batch_read_version_item_count,
|
||||
POOL_META_NATIVE_CAS_METADATA_SUFFIX, PartTransactionAction, PoolMetaNativeBackup, PoolMetaNativeInspection,
|
||||
PoolMetaNativeObject, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse,
|
||||
QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, RUSTFS_META_TMP_BUCKET,
|
||||
ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, is_quota_mutation_fence_path, new_disk,
|
||||
validate_batch_read_version_item_count,
|
||||
};
|
||||
pub use bytes::Bytes;
|
||||
pub use endpoint::Endpoint;
|
||||
@@ -515,13 +526,16 @@ pub mod metrics {
|
||||
pub mod notification {
|
||||
pub use crate::services::notification_sys::{
|
||||
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
|
||||
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
|
||||
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
||||
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
|
||||
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
|
||||
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
|
||||
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
|
||||
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
LegacyTransitionStateReconcileFleetProofToken, MrfLifecycleWriterFleetProofToken, NotificationPeerErr, NotificationSys,
|
||||
ScannerPublicationLeaseGrant, acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
|
||||
acquire_legacy_transition_state_reconcile_fleet_proof, acquire_mrf_lifecycle_writer_fleet_proof,
|
||||
configured_fleet_topology_fingerprint, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
|
||||
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
|
||||
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
|
||||
legacy_transition_state_reconcile_fleet_proof_matches, mrf_lifecycle_reader_local_process_epoch,
|
||||
mrf_lifecycle_writer_fleet_proof_current, mrf_lifecycle_writer_fleet_proof_matches, new_global_notification_sys,
|
||||
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
|
||||
with_mrf_lifecycle_writer_fleet_proof,
|
||||
};
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
pub use crate::services::notification_sys::{
|
||||
|
||||
@@ -87,14 +87,12 @@ use rustfs_filemeta::{
|
||||
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatus, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info,
|
||||
is_restored_object_on_disk,
|
||||
};
|
||||
use rustfs_gateway_types::persistence::PersistedLifecycleConfiguration;
|
||||
use rustfs_gateway_types::persistence::PersistedObjectLockConfiguration as ObjectLockConfiguration;
|
||||
use rustfs_lifecycle::{RestoreRequest, RestoreS3Location};
|
||||
use rustfs_lock::{NamespaceLockGuard, error::LockError};
|
||||
use rustfs_scanner_metrics::metrics::{
|
||||
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
|
||||
};
|
||||
use rustfs_utils::{get_env_i64, get_env_usize, path::encode_dir_object, string::parse_bool};
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ExpirationStatus, ObjectLockConfiguration, RestoreRequest, RestoreRequestType};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::any::Any;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -300,7 +298,7 @@ impl LifecycleSys {
|
||||
Arc::new(Self)
|
||||
}
|
||||
|
||||
pub async fn get(&self, bucket: &str) -> Option<PersistedLifecycleConfiguration> {
|
||||
pub async fn get(&self, bucket: &str) -> Option<BucketLifecycleConfiguration> {
|
||||
match metadata_boundary::get_lifecycle_config(bucket).await {
|
||||
Ok((lc, _)) => Some(lc),
|
||||
Err(Error::ConfigNotFound) => None,
|
||||
@@ -3936,7 +3934,7 @@ pub fn init_background_stale_multipart_upload_cleanup(api: Arc<ECStore>) {
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn validate_transition_tier(lc: &PersistedLifecycleConfiguration) -> Result<(), std::io::Error> {
|
||||
pub async fn validate_transition_tier(lc: &BucketLifecycleConfiguration) -> Result<(), std::io::Error> {
|
||||
for rule in &lc.rules {
|
||||
if let Some(transitions) = &rule.transitions {
|
||||
for transition in transitions {
|
||||
@@ -4053,9 +4051,8 @@ pub(crate) async fn enqueue_immediate_expiry(api: Arc<ECStore>, oi: &ObjectInfo,
|
||||
.map(lifecycle::object_opts_from_object_info)
|
||||
.collect::<Vec<ObjectOpts>>();
|
||||
let lock_config = configs.object_lock;
|
||||
let evaluator_lock_config = lock_config.clone();
|
||||
let events = match Evaluator::new(lifecycle)
|
||||
.with_lock_retention(evaluator_lock_config)
|
||||
.with_lock_retention(lock_config.clone())
|
||||
.eval(&object_opts)
|
||||
.await
|
||||
{
|
||||
@@ -4535,26 +4532,19 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
||||
}
|
||||
}
|
||||
|
||||
fn lifecycle_rule_has_date_expiration(lc: &PersistedLifecycleConfiguration, rule_id: &str) -> bool {
|
||||
fn lifecycle_rule_has_date_expiration(lc: &BucketLifecycleConfiguration, rule_id: &str) -> bool {
|
||||
lc.rules.iter().any(|rule| {
|
||||
rule.status == "Enabled"
|
||||
rule.status == ExpirationStatus::from_static(ExpirationStatus::ENABLED)
|
||||
&& rule.id.as_deref() == Some(rule_id)
|
||||
&& rule.expiration.as_ref().is_some_and(|expiration| expiration.date.is_some())
|
||||
})
|
||||
}
|
||||
|
||||
fn should_defer_date_expiry_for_recent_config_update(lc: &PersistedLifecycleConfiguration, now: OffsetDateTime) -> bool {
|
||||
lc.expiry_updated_at
|
||||
.as_ref()
|
||||
.is_some_and(|updated_at| match rustfs_lifecycle::parse_lifecycle_date(updated_at) {
|
||||
Ok(updated_at) => {
|
||||
now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) < DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "Deferring date expiry with an unreadable update timestamp");
|
||||
true
|
||||
}
|
||||
})
|
||||
fn should_defer_date_expiry_for_recent_config_update(lc: &BucketLifecycleConfiguration, now: OffsetDateTime) -> bool {
|
||||
lc.expiry_updated_at.as_ref().is_some_and(|updated_at| {
|
||||
let updated_at = OffsetDateTime::from(updated_at.clone());
|
||||
now.unix_timestamp().saturating_sub(updated_at.unix_timestamp()) < DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_existing_object_expiry(
|
||||
@@ -4574,7 +4564,7 @@ async fn apply_existing_object_expiry(
|
||||
struct ExistingObjectExpiryContext<'a> {
|
||||
api: Arc<ECStore>,
|
||||
bucket: &'a str,
|
||||
lc: Arc<PersistedLifecycleConfiguration>,
|
||||
lc: Arc<BucketLifecycleConfiguration>,
|
||||
lock_config: Option<Arc<ObjectLockConfiguration>>,
|
||||
bucket_incarnation_id: Uuid,
|
||||
src: &'a LcEventSrc,
|
||||
@@ -4594,9 +4584,8 @@ async fn enqueue_expiry_for_existing_object_group(
|
||||
.iter()
|
||||
.map(lifecycle::object_opts_from_object_info)
|
||||
.collect::<Vec<ObjectOpts>>();
|
||||
let evaluator_lock_config = context.lock_config.clone();
|
||||
let events = match Evaluator::new(context.lc.clone())
|
||||
.with_lock_retention(evaluator_lock_config)
|
||||
.with_lock_retention(context.lock_config.clone())
|
||||
.eval(&object_opts)
|
||||
.await
|
||||
{
|
||||
@@ -4790,7 +4779,7 @@ fn manual_transition_version_marker(oi: &ObjectInfo) -> String {
|
||||
async fn enqueue_transition_with_lifecycle_report(
|
||||
api: Option<Arc<ECStore>>,
|
||||
oi: &ObjectInfo,
|
||||
lc: &PersistedLifecycleConfiguration,
|
||||
lc: &BucketLifecycleConfiguration,
|
||||
src: &LcEventSrc,
|
||||
options: &ManualTransitionRunOptions,
|
||||
report: &mut ManualTransitionRunReport,
|
||||
@@ -4856,7 +4845,7 @@ async fn enqueue_transition_with_lifecycle_report(
|
||||
false
|
||||
}
|
||||
|
||||
async fn enqueue_transition_with_lifecycle(oi: &ObjectInfo, lc: &PersistedLifecycleConfiguration, src: &LcEventSrc) -> bool {
|
||||
async fn enqueue_transition_with_lifecycle(oi: &ObjectInfo, lc: &BucketLifecycleConfiguration, src: &LcEventSrc) -> bool {
|
||||
let event = lc.eval(&oi.to_lifecycle_opts()).await;
|
||||
match event.action {
|
||||
IlmAction::TransitionAction | IlmAction::TransitionVersionAction => {
|
||||
@@ -5370,8 +5359,12 @@ pub async fn post_restore_opts(version_id: &str, bucket: &str, object: &str) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn select_restore_s3_location(rreq: &RestoreRequest) -> Result<Option<&RestoreS3Location>, std::io::Error> {
|
||||
if rreq.type_.as_ref().is_none_or(|type_| type_.as_str() != "SELECT") {
|
||||
fn select_restore_s3_location(rreq: &RestoreRequest) -> Result<Option<&s3s::dto::S3Location>, std::io::Error> {
|
||||
if rreq
|
||||
.type_
|
||||
.as_ref()
|
||||
.is_none_or(|type_| type_.as_str() != RestoreRequestType::SELECT)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let output_location = rreq
|
||||
@@ -5413,25 +5406,27 @@ pub async fn put_restore_opts(
|
||||
// the OutputLocation metadata instead of a write to `OutputLocation.S3`
|
||||
// (backlog#1341). RestoreObject rejects SELECT at the API boundary; this
|
||||
// is the fail-closed backstop for any other caller.
|
||||
if rreq.type_.as_ref().is_some_and(|type_| type_.as_str() == "SELECT") {
|
||||
if rreq
|
||||
.type_
|
||||
.as_ref()
|
||||
.is_some_and(|type_| type_.as_str() == RestoreRequestType::SELECT)
|
||||
{
|
||||
return Err(std::io::Error::other("SELECT restore requests are not supported"));
|
||||
}
|
||||
for (k, v) in oi.user_defined.iter() {
|
||||
meta.insert(k.to_string(), v.clone());
|
||||
}
|
||||
// The fleet capability gate guarantees physical copy-back support. Keep
|
||||
// the completed transition identity for orphan recovery and remote cleanup.
|
||||
rustfs_utils::http::metadata_compat::remove_str(&mut meta, rustfs_utils::http::metadata_compat::SUFFIX_RESTORE_OPERATION_ID);
|
||||
rustfs_utils::http::metadata_compat::remove_str(&mut meta, rustfs_utils::http::metadata_compat::SUFFIX_RESTORE_WORKER_LOCK);
|
||||
if !oi.user_tags.is_empty() {
|
||||
meta.insert(AMZ_OBJECT_TAGGING.to_string(), (*oi.user_tags).clone());
|
||||
}
|
||||
// Finalization publishes completion and its expiry under the object lock.
|
||||
// The local copy-back commit must not make unfinished work eligible for expiry.
|
||||
let restore_expiry = lifecycle::expected_expiry_time(OffsetDateTime::now_utc(), rreq.days.unwrap_or(1));
|
||||
meta.insert(
|
||||
metadata_keys::RESTORE.to_string(),
|
||||
RestoreStatus {
|
||||
is_restore_in_progress: Some(true),
|
||||
restore_expiry_date: None,
|
||||
is_restore_in_progress: Some(false),
|
||||
restore_expiry_date: Some(restore_expiry),
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
@@ -5445,7 +5440,6 @@ pub async fn put_restore_opts(
|
||||
// computed MD5 is not the object's public plaintext ETag.
|
||||
preserve_etag: oi.etag.clone(),
|
||||
shard_integrity_write_mode: Some(oi.shard_integrity_write_mode()),
|
||||
restore_copyback: true,
|
||||
//expires: oi.expires,
|
||||
..Default::default()
|
||||
})
|
||||
@@ -5465,18 +5459,6 @@ impl LifecycleOps for ObjectInfo {
|
||||
if self.transitioned_object.status != lifecycle::TRANSITION_COMPLETE {
|
||||
return false;
|
||||
}
|
||||
if rustfs_utils::http::metadata_compat::get_consistent_str(
|
||||
&self.user_defined,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_RESTORE_COPYBACK,
|
||||
) == Some(rustfs_utils::http::metadata_compat::RESTORE_COPYBACK_PROTOCOL_V1)
|
||||
&& self
|
||||
.user_defined
|
||||
.get(metadata_keys::RESTORE)
|
||||
.and_then(|value| rustfs_filemeta::parse_restore_obj_status(value).ok())
|
||||
.is_some_and(|status| status.is_restore_in_progress == Some(true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
!is_restored_object_on_disk(&self.user_defined)
|
||||
}
|
||||
}
|
||||
@@ -5488,25 +5470,25 @@ pub trait RestoreRequestOps {
|
||||
impl RestoreRequestOps for RestoreRequest {
|
||||
fn validate(&self, _api: Arc<ECStore>) -> Result<(), std::io::Error> {
|
||||
// SELECT type requires select_parameters, and vice versa
|
||||
if self.type_.as_ref().is_none_or(|t| t.as_str() != "SELECT") && self.select_parameters.is_some() {
|
||||
if self.type_.as_ref().is_none_or(|t| t.as_str() != RestoreRequestType::SELECT) && self.select_parameters.is_some() {
|
||||
return Err(std::io::Error::other("Select parameters can only be specified with SELECT request type"));
|
||||
}
|
||||
if let Some(type_) = &self.type_
|
||||
&& type_.as_str() == "SELECT"
|
||||
&& type_.as_str() == RestoreRequestType::SELECT
|
||||
&& self.select_parameters.is_none()
|
||||
{
|
||||
return Err(std::io::Error::other("SELECT restore request requires select parameters to be specified"));
|
||||
}
|
||||
|
||||
// OutputLocation is only valid for SELECT requests
|
||||
if self.type_.as_ref().is_none_or(|t| t.as_str() != "SELECT") && self.output_location.is_some() {
|
||||
if self.type_.as_ref().is_none_or(|t| t.as_str() != RestoreRequestType::SELECT) && self.output_location.is_some() {
|
||||
return Err(std::io::Error::other("OutputLocation can only be specified with SELECT request type"));
|
||||
}
|
||||
select_restore_s3_location(self)?;
|
||||
|
||||
// Days must not be specified with SELECT requests
|
||||
if let Some(type_) = &self.type_
|
||||
&& type_.as_str() == "SELECT"
|
||||
&& type_.as_str() == RestoreRequestType::SELECT
|
||||
&& self.days.is_some_and(|d| d > 0)
|
||||
{
|
||||
return Err(std::io::Error::other("Days cannot be specified with SELECT restore request"));
|
||||
@@ -5548,7 +5530,7 @@ pub fn lifecycle_version_delete_target(oi: &ObjectInfo) -> Option<ObjectToDelete
|
||||
}
|
||||
|
||||
pub async fn eval_action_from_lifecycle(
|
||||
lc: &PersistedLifecycleConfiguration,
|
||||
lc: &BucketLifecycleConfiguration,
|
||||
lock_config: Option<&ObjectLockConfiguration>,
|
||||
oi: &ObjectInfo,
|
||||
) -> lifecycle::Event {
|
||||
@@ -6253,19 +6235,14 @@ mod tests {
|
||||
use rustfs_data_usage::TierStats;
|
||||
use rustfs_filemeta::metadata_keys;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta};
|
||||
use rustfs_gateway_types::dto::MetadataEntry;
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedDefaultRetention as DefaultRetention, PersistedObjectLockConfiguration as ObjectLockConfiguration,
|
||||
PersistedObjectLockRule as ObjectLockRule,
|
||||
};
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedLifecycleConfiguration, PersistedLifecycleExpiration, PersistedLifecycleRule,
|
||||
PersistedNoncurrentVersionExpiration, PersistedTransition,
|
||||
};
|
||||
use rustfs_lifecycle::{RestoreOutputLocation as OutputLocation, RestoreRequest, RestoreS3Location as S3Location};
|
||||
#[cfg(feature = "test-util")]
|
||||
use rustfs_s3_client::transition_api::ReaderImpl;
|
||||
use rustfs_scanner_metrics::metrics::{IlmAction, global_metrics};
|
||||
use s3s::dto::{
|
||||
BucketLifecycleConfiguration, DefaultRetention, ExpirationStatus, LifecycleExpiration, LifecycleRule, MetadataEntry,
|
||||
NoncurrentVersionExpiration, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule,
|
||||
OutputLocation, RestoreRequest, RestoreRequestType, S3Location, Timestamp, Transition, TransitionStorageClass,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
@@ -9272,7 +9249,7 @@ mod tests {
|
||||
output_location,
|
||||
select_parameters: None,
|
||||
tier: None,
|
||||
type_: Some("SELECT".to_owned()),
|
||||
type_: Some(RestoreRequestType::from_static(RestoreRequestType::SELECT)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9589,12 +9566,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lifecycle_rule_has_date_expiration_detects_enabled_date_rule() {
|
||||
let lc = PersistedLifecycleConfiguration {
|
||||
let lc = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: Some(PersistedLifecycleExpiration {
|
||||
date: Some(lifecycle::format_lifecycle_date(OffsetDateTime::now_utc()).unwrap()),
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
date: Some(Timestamp::from(OffsetDateTime::now_utc())),
|
||||
..Default::default()
|
||||
}),
|
||||
id: Some("rule-date".to_string()),
|
||||
@@ -10111,15 +10088,14 @@ mod tests {
|
||||
#[test]
|
||||
fn should_defer_date_expiry_for_recent_config_update_respects_grace_window() {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let recent = PersistedLifecycleConfiguration {
|
||||
expiry_updated_at: Some(lifecycle::format_lifecycle_date(now - time::Duration::seconds(1)).unwrap()),
|
||||
let recent = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: Some(Timestamp::from(now - time::Duration::seconds(1))),
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let stale = PersistedLifecycleConfiguration {
|
||||
expiry_updated_at: Some(
|
||||
lifecycle::format_lifecycle_date(now - time::Duration::seconds(DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS + 1))
|
||||
.unwrap(),
|
||||
),
|
||||
let stale = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: Some(Timestamp::from(
|
||||
now - time::Duration::seconds(DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS + 1),
|
||||
)),
|
||||
rules: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -10247,12 +10223,12 @@ mod tests {
|
||||
assert_eq!(deleted.version_id, None);
|
||||
}
|
||||
|
||||
fn expired_delete_marker_lifecycle() -> PersistedLifecycleConfiguration {
|
||||
PersistedLifecycleConfiguration {
|
||||
fn expired_delete_marker_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: Some(PersistedLifecycleExpiration {
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
expired_object_delete_marker: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -10268,12 +10244,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_expiration_lifecycle() -> PersistedLifecycleConfiguration {
|
||||
PersistedLifecycleConfiguration {
|
||||
fn latest_expiration_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: Some(PersistedLifecycleExpiration {
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -10289,12 +10265,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_and_noncurrent_expiration_lifecycle() -> PersistedLifecycleConfiguration {
|
||||
PersistedLifecycleConfiguration {
|
||||
fn current_and_noncurrent_expiration_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: Some(PersistedLifecycleExpiration {
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(30),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -10302,7 +10278,7 @@ mod tests {
|
||||
del_marker_expiration: None,
|
||||
filter: None,
|
||||
id: Some("expire-current-and-noncurrent".to_string()),
|
||||
noncurrent_version_expiration: Some(PersistedNoncurrentVersionExpiration {
|
||||
noncurrent_version_expiration: Some(NoncurrentVersionExpiration {
|
||||
noncurrent_days: Some(1),
|
||||
newer_noncurrent_versions: None,
|
||||
}),
|
||||
@@ -10313,12 +10289,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn all_versions_expiration_lifecycle() -> PersistedLifecycleConfiguration {
|
||||
PersistedLifecycleConfiguration {
|
||||
fn all_versions_expiration_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: Some(PersistedLifecycleExpiration {
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
expired_object_all_versions: Some(true),
|
||||
..Default::default()
|
||||
@@ -10337,29 +10313,29 @@ mod tests {
|
||||
|
||||
fn lock_enabled_without_default_retention() -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_enabled_with_default_retention() -> ObjectLockConfiguration {
|
||||
ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
days: Some(30),
|
||||
mode: Some("COMPLIANCE".to_owned()),
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_transition_lifecycle() -> PersistedLifecycleConfiguration {
|
||||
PersistedLifecycleConfiguration {
|
||||
fn latest_transition_lifecycle() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: None,
|
||||
abort_incomplete_multipart_upload: None,
|
||||
del_marker_expiration: None,
|
||||
@@ -10368,10 +10344,10 @@ mod tests {
|
||||
noncurrent_version_expiration: None,
|
||||
noncurrent_version_transitions: None,
|
||||
prefix: None,
|
||||
transitions: Some(vec![PersistedTransition {
|
||||
transitions: Some(vec![Transition {
|
||||
days: Some(1),
|
||||
date: None,
|
||||
storage_class: Some("WARM".to_owned()),
|
||||
storage_class: Some(TransitionStorageClass::from_static("WARM")),
|
||||
}]),
|
||||
}],
|
||||
}
|
||||
@@ -11338,13 +11314,7 @@ mod tests {
|
||||
let marker = marker
|
||||
.as_deref()
|
||||
.expect("production checkpoint should persist an object marker");
|
||||
let marker_suffix = marker
|
||||
.strip_prefix(&expected_marker)
|
||||
.expect("production checkpoint should stop at the first page boundary");
|
||||
assert!(
|
||||
marker_suffix.is_empty() || marker_suffix.starts_with("[rustfs_cache:v2,"),
|
||||
"unexpected production checkpoint marker suffix: {marker_suffix}"
|
||||
);
|
||||
assert_eq!(marker, expected_marker, "production checkpoint should stop at the first page boundary");
|
||||
assert_eq!(version_marker.as_deref(), Some("null"));
|
||||
}
|
||||
|
||||
@@ -12914,9 +12884,8 @@ mod tests {
|
||||
|
||||
for (name, object, lock_config, expected) in cases {
|
||||
let object_opts = lifecycle::object_opts_from_object_info(&object);
|
||||
let evaluator_lock_config = lock_config.clone().map(Arc::new);
|
||||
let batch_event = Evaluator::new(Arc::new(lifecycle.clone()))
|
||||
.with_lock_retention(evaluator_lock_config)
|
||||
.with_lock_retention(lock_config.clone().map(Arc::new))
|
||||
.eval(&[object_opts])
|
||||
.await
|
||||
.expect("batch lifecycle evaluation should succeed")
|
||||
@@ -14173,7 +14142,10 @@ mod tests {
|
||||
let object = current_object_with_metadata(
|
||||
ReplicationStatusType::Completed,
|
||||
HashMap::from([
|
||||
(metadata_keys::OBJECT_LOCK_MODE.to_string(), "COMPLIANCE".to_string()),
|
||||
(
|
||||
metadata_keys::OBJECT_LOCK_MODE.to_string(),
|
||||
s3s::dto::ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||
),
|
||||
(metadata_keys::OBJECT_LOCK_RETAIN_UNTIL_DATE.to_string(), retain_until),
|
||||
]),
|
||||
);
|
||||
@@ -14185,7 +14157,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_copy_expiry_is_not_blocked_by_retention() {
|
||||
let lifecycle = PersistedLifecycleConfiguration {
|
||||
let lifecycle = BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: Vec::new(),
|
||||
};
|
||||
@@ -14195,7 +14167,10 @@ mod tests {
|
||||
let mut object = current_object_with_metadata(
|
||||
ReplicationStatusType::Completed,
|
||||
HashMap::from([
|
||||
(metadata_keys::OBJECT_LOCK_MODE.to_string(), "COMPLIANCE".to_string()),
|
||||
(
|
||||
metadata_keys::OBJECT_LOCK_MODE.to_string(),
|
||||
ObjectLockRetentionMode::COMPLIANCE.to_string(),
|
||||
),
|
||||
(metadata_keys::OBJECT_LOCK_RETAIN_UNTIL_DATE.to_string(), retain_until),
|
||||
]),
|
||||
);
|
||||
@@ -14412,7 +14387,7 @@ mod tests {
|
||||
"removed" => {
|
||||
metadata_sys::delete(&bucket, BUCKET_LIFECYCLE_CONFIG).await.unwrap();
|
||||
}
|
||||
"disabled" => changed.rules[0].status = "Disabled".to_owned(),
|
||||
"disabled" => changed.rules[0].status = ExpirationStatus::from_static(ExpirationStatus::DISABLED),
|
||||
"longer-days" => {
|
||||
changed.rules[0]
|
||||
.noncurrent_version_expiration
|
||||
@@ -14528,7 +14503,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn install_test_expiry_policy(ecstore: &Arc<ECStore>, bucket: &str) -> Arc<PersistedLifecycleConfiguration> {
|
||||
async fn install_test_expiry_policy(ecstore: &Arc<ECStore>, bucket: &str) -> Arc<BucketLifecycleConfiguration> {
|
||||
let xml = br#"<LifecycleConfiguration><Rule><ID>expire</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Expiration><Days>0</Days></Expiration><NoncurrentVersionExpiration><NoncurrentDays>0</NoncurrentDays></NoncurrentVersionExpiration></Rule></LifecycleConfiguration>"#;
|
||||
metadata_sys::update_in(&ecstore.ctx, bucket, BUCKET_LIFECYCLE_CONFIG, xml.to_vec())
|
||||
.await
|
||||
@@ -15001,11 +14976,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
|
||||
let (disk_paths, ecstore) =
|
||||
temp_env::async_with_vars([(super::ENV_TIER_FREE_VERSION_RECOVERY_ENABLED, Some("false"))], async {
|
||||
setup_test_env().await
|
||||
})
|
||||
.await;
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
|
||||
crate::services::tier::test_util::register_mock_tier(&ecstore.tier_config_mgr(), &tier).await;
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::object_api::ObjectInfo;
|
||||
pub use rustfs_lifecycle::{
|
||||
Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate, ObjectOpts,
|
||||
RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due,
|
||||
expected_expiry_time, expiration_action_has_valid_target, format_lifecycle_date, parse_lifecycle_date,
|
||||
expected_expiry_time, expiration_action_has_valid_target,
|
||||
};
|
||||
|
||||
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_gateway_types::persistence::PersistedLifecycleConfiguration;
|
||||
use rustfs_gateway_types::persistence::PersistedObjectLockConfiguration as ObjectLockConfiguration;
|
||||
use s3s::dto::{BucketLifecycleConfiguration, ObjectLockConfiguration};
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -25,7 +24,7 @@ use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LifecycleExpiryConfigs {
|
||||
pub(crate) lifecycle: Option<Arc<PersistedLifecycleConfiguration>>,
|
||||
pub(crate) lifecycle: Option<Arc<BucketLifecycleConfiguration>>,
|
||||
pub(crate) object_lock: Option<Arc<ObjectLockConfiguration>>,
|
||||
pub(crate) bucket_incarnation_id: Uuid,
|
||||
pub(crate) table_bucket_enabled: bool,
|
||||
@@ -108,7 +107,7 @@ pub(crate) async fn get_expiry_configs_for_options(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn get_lifecycle_config(bucket: &str) -> Result<(PersistedLifecycleConfiguration, OffsetDateTime)> {
|
||||
pub(crate) async fn get_lifecycle_config(bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_lifecycle_config(bucket).await
|
||||
}
|
||||
|
||||
@@ -118,15 +117,15 @@ mod tests {
|
||||
use crate::bucket::metadata::BucketMetadata;
|
||||
use crate::bucket::metadata_sys::{self, test_support::isolated_store_over_temp_disks};
|
||||
use crate::storage_api_contracts::bucket::MakeBucketOptions;
|
||||
use rustfs_gateway_types::persistence::{PersistedLifecycleExpiration, PersistedLifecycleRule};
|
||||
use s3s::dto::{ExpirationStatus, LifecycleExpiration, LifecycleRule};
|
||||
use serial_test::serial;
|
||||
|
||||
fn lifecycle_config() -> PersistedLifecycleConfiguration {
|
||||
PersistedLifecycleConfiguration {
|
||||
fn lifecycle_config() -> BucketLifecycleConfiguration {
|
||||
BucketLifecycleConfiguration {
|
||||
expiry_updated_at: None,
|
||||
rules: vec![PersistedLifecycleRule {
|
||||
status: "Enabled".to_owned(),
|
||||
expiration: Some(PersistedLifecycleExpiration {
|
||||
rules: vec![LifecycleRule {
|
||||
status: ExpirationStatus::from_static(ExpirationStatus::ENABLED),
|
||||
expiration: Some(LifecycleExpiration {
|
||||
days: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
|
||||
@@ -22,7 +22,7 @@ pub(crate) fn is_object_locked_by_metadata(user_defined: &HashMap<String, String
|
||||
}
|
||||
|
||||
pub(crate) fn check_object_lock_for_deletion_with_config(
|
||||
config: Option<&rustfs_gateway_types::persistence::PersistedObjectLockConfiguration>,
|
||||
config: Option<&s3s::dto::ObjectLockConfiguration>,
|
||||
obj_info: &ObjectInfo,
|
||||
bypass_governance: bool,
|
||||
) -> crate::error::Result<Option<ObjectLockBlockReason>> {
|
||||
|
||||
@@ -82,13 +82,12 @@ mod tests {
|
||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||
use crate::object_api::{ObjectInfo, ObjectOptions};
|
||||
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedOptionalReplicationStatus, PersistedReplicationConfiguration, PersistedReplicationDestination,
|
||||
PersistedReplicationRule, PersistedReplicationStatus,
|
||||
};
|
||||
use rustfs_scanner_metrics::metrics::IlmAction;
|
||||
use s3s::dto::{
|
||||
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
|
||||
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
|
||||
VersioningConfiguration,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
@@ -161,27 +160,27 @@ mod tests {
|
||||
for purge_enabled in [false, true] {
|
||||
let snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||
VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
},
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![PersistedReplicationRule {
|
||||
delete_marker_replication: Some(PersistedOptionalReplicationStatus {
|
||||
rules: vec![ReplicationRule {
|
||||
delete_marker_replication: Some(DeleteMarkerReplication {
|
||||
status: Some(if marker_enabled {
|
||||
Status::ENABLED.to_string()
|
||||
DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)
|
||||
} else {
|
||||
Status::DISABLED.to_string()
|
||||
DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)
|
||||
}),
|
||||
}),
|
||||
delete_replication: Some(PersistedReplicationStatus {
|
||||
delete_replication: Some(DeleteReplication {
|
||||
status: if purge_enabled {
|
||||
Status::ENABLED.to_string()
|
||||
DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED)
|
||||
} else {
|
||||
Status::DISABLED.to_string()
|
||||
DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED)
|
||||
},
|
||||
}),
|
||||
destination: PersistedReplicationDestination {
|
||||
destination: Destination {
|
||||
bucket: "arn:rustfs:replication:target".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -191,7 +190,7 @@ mod tests {
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: Status::ENABLED.to_string(),
|
||||
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||
}],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustfs_gateway_types::persistence::PersistedLifecycleConfiguration;
|
||||
use s3s::dto::BucketLifecycleConfiguration;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(crate) fn deployment_id() -> Option<String> {
|
||||
sources::deployment_id()
|
||||
}
|
||||
|
||||
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<PersistedLifecycleConfiguration> {
|
||||
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> {
|
||||
sources::bucket_lifecycle_config(bucket).await
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::{
|
||||
};
|
||||
|
||||
use futures::StreamExt;
|
||||
use rand::RngExt as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -70,6 +71,7 @@ pub(crate) const EVENT_LIFECYCLE_TIER_DELETE_JOURNAL: &str = "lifecycle_tier_del
|
||||
// successful pages while its wall-clock budget remains.
|
||||
pub const DEFAULT_TIER_DELETE_JOURNAL_RECOVERY_LIMIT: usize = 8;
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
const TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET: Duration = Duration::from_secs(240);
|
||||
const TIER_DELETE_REMOTE_DEADLINE: Duration = Duration::from_secs(30);
|
||||
@@ -6104,57 +6106,114 @@ pub(crate) async fn recover_test_tier_delete_journal_pass_with_budget(
|
||||
recover_tier_delete_journal_pass(api, cancel_token, manifest_marker, journal_marker, budget).await
|
||||
}
|
||||
|
||||
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
||||
fn tier_delete_journal_recovery_pass_is_idle(stats: &TierDeleteJournalRecoveryPassStats) -> bool {
|
||||
stats.manifest_pages > 0
|
||||
&& stats.journal_pages > 0
|
||||
&& stats.manifest_errors == 0
|
||||
&& stats.journal_errors == 0
|
||||
&& !stats.deadline_exhausted
|
||||
&& !stats.canceled
|
||||
&& stats.manifests == TierDeleteDispatchManifestRecoveryStats::default()
|
||||
&& stats.journals == TierDeleteJournalRecoveryStats::default()
|
||||
}
|
||||
|
||||
fn bounded_tier_delete_journal_recovery_delay(delay: Duration, percent: u32) -> Duration {
|
||||
(delay.saturating_mul(percent) / 100)
|
||||
.clamp(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL)
|
||||
}
|
||||
|
||||
fn jitter_tier_delete_journal_recovery_delay(delay: Duration) -> Duration {
|
||||
bounded_tier_delete_journal_recovery_delay(delay, rand::rng().random_range(90..=110))
|
||||
}
|
||||
|
||||
async fn drive_tier_delete_journal_recovery(
|
||||
cancel_token: &CancellationToken,
|
||||
wait_for_wakeup: impl AsyncFn(),
|
||||
mut recover: impl AsyncFnMut() -> TierDeleteJournalRecoveryPassStats,
|
||||
jitter_delay: fn(Duration) -> Duration,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut marker: Option<String> = None;
|
||||
let mut manifest_marker: Option<String> = None;
|
||||
let mut idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => return,
|
||||
_ = wait_for_wakeup() => {
|
||||
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||
interval.reset();
|
||||
},
|
||||
_ = interval.tick() => {},
|
||||
_ = api.ctx.wait_for_tier_delete_journal_recovery() => {},
|
||||
}
|
||||
|
||||
let stats = recover_tier_delete_journal_pass(
|
||||
api.clone(),
|
||||
&cancel_token,
|
||||
&mut manifest_marker,
|
||||
&mut marker,
|
||||
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
|
||||
)
|
||||
.await;
|
||||
let stats = recover().await;
|
||||
if stats.canceled {
|
||||
return;
|
||||
}
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
manifest_pages = stats.manifest_pages,
|
||||
manifest_scanned = stats.manifests.scanned,
|
||||
manifest_advanced = stats.manifests.advanced,
|
||||
manifest_deleted = stats.manifests.deleted,
|
||||
manifest_retained = stats.manifests.retained,
|
||||
manifest_failed = stats.manifests.failed,
|
||||
manifest_errors = stats.manifest_errors,
|
||||
manifest_truncated = stats.manifests.truncated,
|
||||
manifest_next_marker = ?manifest_marker,
|
||||
journal_pages = stats.journal_pages,
|
||||
journal_scanned = stats.journals.scanned,
|
||||
journal_deleted = stats.journals.deleted,
|
||||
journal_failed = stats.journals.failed,
|
||||
journal_errors = stats.journal_errors,
|
||||
journal_truncated = stats.journals.truncated,
|
||||
journal_next_marker = ?marker,
|
||||
deadline_exhausted = stats.deadline_exhausted,
|
||||
"Recovered tier delete journal pass"
|
||||
);
|
||||
if tier_delete_journal_recovery_pass_is_idle(&stats) {
|
||||
// Only a completed empty sweep may defer discovery. Retained,
|
||||
// failed, or paginated work keeps the original retry tick budget.
|
||||
interval.reset_after(jitter_delay(idle_interval));
|
||||
idle_interval = idle_interval
|
||||
.saturating_mul(2)
|
||||
.min(TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
|
||||
} else {
|
||||
idle_interval = TIER_DELETE_JOURNAL_RECOVERY_INTERVAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_tier_delete_journal_recovery_loop(api: Arc<ECStore>, cancel_token: CancellationToken) {
|
||||
let mut marker: Option<String> = None;
|
||||
let mut manifest_marker: Option<String> = None;
|
||||
let wake_context = api.ctx.clone();
|
||||
let recovery_cancel_token = cancel_token.clone();
|
||||
drive_tier_delete_journal_recovery(
|
||||
&cancel_token,
|
||||
async move || wake_context.wait_for_tier_delete_journal_recovery().await,
|
||||
async move || {
|
||||
let stats = recover_tier_delete_journal_pass(
|
||||
api.clone(),
|
||||
&recovery_cancel_token,
|
||||
&mut manifest_marker,
|
||||
&mut marker,
|
||||
TIER_DELETE_JOURNAL_RECOVERY_PASS_BUDGET,
|
||||
)
|
||||
.await;
|
||||
if stats.canceled {
|
||||
return stats;
|
||||
}
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
manifest_pages = stats.manifest_pages,
|
||||
manifest_scanned = stats.manifests.scanned,
|
||||
manifest_advanced = stats.manifests.advanced,
|
||||
manifest_deleted = stats.manifests.deleted,
|
||||
manifest_retained = stats.manifests.retained,
|
||||
manifest_failed = stats.manifests.failed,
|
||||
manifest_errors = stats.manifest_errors,
|
||||
manifest_truncated = stats.manifests.truncated,
|
||||
manifest_next_marker = ?manifest_marker,
|
||||
journal_pages = stats.journal_pages,
|
||||
journal_scanned = stats.journals.scanned,
|
||||
journal_deleted = stats.journals.deleted,
|
||||
journal_failed = stats.journals.failed,
|
||||
journal_errors = stats.journal_errors,
|
||||
journal_truncated = stats.journals.truncated,
|
||||
journal_next_marker = ?marker,
|
||||
deadline_exhausted = stats.deadline_exhausted,
|
||||
"Recovered tier delete journal pass"
|
||||
);
|
||||
stats
|
||||
},
|
||||
jitter_tier_delete_journal_recovery_delay,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn await_tier_delete_journal_recovery<T, F>(
|
||||
cancel_token: &CancellationToken,
|
||||
timeout: Duration,
|
||||
@@ -6180,16 +6239,20 @@ mod tests {
|
||||
use super::{
|
||||
PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
|
||||
TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
|
||||
TIER_DELETE_JOURNAL_RECOVERY_INTERVAL, TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL,
|
||||
TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
|
||||
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
|
||||
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
|
||||
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
|
||||
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
|
||||
TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest,
|
||||
TierDeleteDispatchManifestRecoveryStats, TierDeleteDispatchManifestState, TierDeleteDispatchParent,
|
||||
TierDeleteDispatchParentState, TierDeleteDispatchRecord, TierDeleteJournalRecoveryPassStats,
|
||||
await_tier_delete_journal_recovery, bounded_tier_delete_journal_recovery_delay, decode_tier_delete_dispatch_record,
|
||||
decode_tier_delete_journal_entry, drive_tier_delete_journal_recovery, encode_tier_delete_dispatch_manifest,
|
||||
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
|
||||
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
|
||||
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
|
||||
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
|
||||
tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
|
||||
tier_delete_journal_recovery_pass_is_idle, tier_delete_source_matches_dispatch_scope,
|
||||
validate_legacy_tier_delete_recovery_source,
|
||||
};
|
||||
use crate::bucket::lifecycle::tier_sweeper::{
|
||||
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
|
||||
@@ -6833,6 +6896,213 @@ mod tests {
|
||||
assert!(err.to_string().contains("decode tier delete journal failed"));
|
||||
}
|
||||
|
||||
fn empty_recovery_pass() -> TierDeleteJournalRecoveryPassStats {
|
||||
TierDeleteJournalRecoveryPassStats {
|
||||
manifest_pages: 1,
|
||||
journal_pages: 1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_recovery_requires_both_completed_empty_queues_for_idle() {
|
||||
assert!(tier_delete_journal_recovery_pass_is_idle(&empty_recovery_pass()));
|
||||
let mut cases = Vec::new();
|
||||
cases.push(TierDeleteJournalRecoveryPassStats::default());
|
||||
for change in [
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_pages = 0,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_pages = 0,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifest_errors = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journal_errors = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.deadline_exhausted = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.canceled = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.scanned = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.retained = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.failed = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.truncated = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.manifests.next_marker = Some("next".to_owned()),
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.scanned = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.failed = 1,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.truncated = true,
|
||||
|stats: &mut TierDeleteJournalRecoveryPassStats| stats.journals.next_marker = Some("next".to_owned()),
|
||||
] {
|
||||
let mut stats = empty_recovery_pass();
|
||||
change(&mut stats);
|
||||
cases.push(stats);
|
||||
}
|
||||
for stats in cases {
|
||||
assert!(
|
||||
!tier_delete_journal_recovery_pass_is_idle(&stats),
|
||||
"unfinished recovery was considered idle: {stats:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_delete_journal_recovery_jitter_preserves_discovery_bounds() {
|
||||
for delay in [
|
||||
Duration::ZERO,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(120),
|
||||
Duration::from_secs(300),
|
||||
Duration::MAX,
|
||||
] {
|
||||
for percent in [90, 100, 110] {
|
||||
let jittered = bounded_tier_delete_journal_recovery_delay(delay, percent);
|
||||
assert!(jittered >= TIER_DELETE_JOURNAL_RECOVERY_INTERVAL);
|
||||
assert!(jittered <= TIER_DELETE_JOURNAL_RECOVERY_MAX_IDLE_INTERVAL);
|
||||
if delay == Duration::from_secs(120) {
|
||||
assert!((Duration::from_secs(108)..=Duration::from_secs(132)).contains(&jittered));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_starts_immediately_backs_off_and_wakes() {
|
||||
let cancel = CancellationToken::new();
|
||||
let wake = tokio::sync::Notify::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let started = tokio::time::Instant::now();
|
||||
let recovery = drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || wake.notified().await,
|
||||
async || {
|
||||
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||
empty_recovery_pass()
|
||||
},
|
||||
|delay| bounded_tier_delete_journal_recovery_delay(delay, 110),
|
||||
);
|
||||
let exercise = async {
|
||||
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
for delay in [66, 132, 264, 300, 300] {
|
||||
tokio::time::advance(Duration::from_secs(delay - 1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(rx.try_recv().is_err(), "idle pass ran before its backoff");
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
elapsed += Duration::from_secs(delay);
|
||||
assert_eq!(rx.recv().await.expect("bounded idle recovery must run"), started + elapsed);
|
||||
}
|
||||
wake.notify_one();
|
||||
assert_eq!(rx.recv().await.expect("notification must bypass idle delay"), started + elapsed);
|
||||
let first_idle_delay = Duration::from_secs(66);
|
||||
tokio::time::advance(first_idle_delay).await;
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("wakeup must reset idle backoff"),
|
||||
started + elapsed + first_idle_delay
|
||||
);
|
||||
cancel.cancel();
|
||||
};
|
||||
tokio::join!(recovery, exercise);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_preserves_known_work_tick_deadline() {
|
||||
let cancel = CancellationToken::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut pass = 0;
|
||||
let recovery = drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || std::future::pending::<()>().await,
|
||||
async || {
|
||||
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||
pass += 1;
|
||||
if pass <= 3 {
|
||||
return empty_recovery_pass();
|
||||
}
|
||||
if pass == 4 {
|
||||
tokio::time::sleep(Duration::from_secs(40)).await;
|
||||
}
|
||||
let mut stats = empty_recovery_pass();
|
||||
match pass {
|
||||
4 => {
|
||||
stats.manifests = TierDeleteDispatchManifestRecoveryStats {
|
||||
retained: 1,
|
||||
scanned: 1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
5 => stats.manifest_errors = 1,
|
||||
6 => stats.journal_errors = 1,
|
||||
7 => stats.manifests.truncated = true,
|
||||
8 => stats.journals.failed = 1,
|
||||
9 => stats.deadline_exhausted = true,
|
||||
_ => stats.journals.next_marker = Some("next-page".to_owned()),
|
||||
}
|
||||
stats
|
||||
},
|
||||
std::convert::identity,
|
||||
);
|
||||
let exercise = async {
|
||||
for expected in [0, 60, 180, 420] {
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("idle and work passes must run"),
|
||||
started + Duration::from_secs(expected)
|
||||
);
|
||||
}
|
||||
tokio::time::advance(Duration::from_secs(59)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(rx.try_recv().is_err(), "known work ran before the next tick");
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
for expected in [480, 540, 600, 660, 720, 780, 840] {
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("unfinished work must retain the base tick deadline"),
|
||||
started + Duration::from_secs(expected)
|
||||
);
|
||||
}
|
||||
cancel.cancel();
|
||||
};
|
||||
tokio::join!(recovery, exercise);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_keeps_wakeup_received_during_a_pass() {
|
||||
let cancel = CancellationToken::new();
|
||||
let wake = tokio::sync::Notify::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let started = tokio::time::Instant::now();
|
||||
let mut first = true;
|
||||
let recovery = drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || wake.notified().await,
|
||||
async || {
|
||||
tx.send(tokio::time::Instant::now()).expect("test must observe each pass");
|
||||
if first {
|
||||
first = false;
|
||||
tokio::time::sleep(Duration::from_secs(40)).await;
|
||||
}
|
||||
empty_recovery_pass()
|
||||
},
|
||||
std::convert::identity,
|
||||
);
|
||||
let exercise = async {
|
||||
assert_eq!(rx.recv().await.expect("startup recovery must run"), started);
|
||||
wake.notify_one();
|
||||
tokio::time::advance(Duration::from_secs(40)).await;
|
||||
assert_eq!(
|
||||
rx.recv().await.expect("a notification during recovery must remain pending"),
|
||||
started + Duration::from_secs(40)
|
||||
);
|
||||
cancel.cancel();
|
||||
};
|
||||
tokio::join!(recovery, exercise);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_delete_journal_recovery_loop_cancellation_wins_over_ready_wakeup() {
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
drive_tier_delete_journal_recovery(
|
||||
&cancel,
|
||||
async || {},
|
||||
async || panic!("shutdown must prevent admission even when a wakeup and startup tick are ready"),
|
||||
std::convert::identity,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_delete_journal_recovery_has_a_hard_outer_timeout() {
|
||||
let result = await_tier_delete_journal_recovery(
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::msgp_decode::{read_msgp_ext8_time, skip_msgp_value, write_msgp_time};
|
||||
use super::object_lock::ObjectLockApi;
|
||||
use super::object_lock::{ObjectLockApi, ObjectLockStatusExt};
|
||||
use super::versioning::VersioningApi;
|
||||
use super::{quota::BucketQuota, target::BucketTargets};
|
||||
use crate::bucket::replication::invalid_replication_config_status_field;
|
||||
@@ -25,17 +25,12 @@ use crate::object_api::WriteCompletion;
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::store::ECStore;
|
||||
use byteorder::{BigEndian, ByteOrder, LittleEndian};
|
||||
use rustfs_gateway_types::cors_tagging::{PersistedCorsConfiguration as CORSConfiguration, PersistedTagging as Tagging};
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedAccelerateConfiguration as AccelerateConfiguration,
|
||||
PersistedBucketEncryptionConfiguration as ServerSideEncryptionConfiguration,
|
||||
PersistedBucketLoggingStatus as BucketLoggingStatus, PersistedLifecycleConfiguration,
|
||||
PersistedNotificationConfiguration as NotificationConfiguration, PersistedObjectLockConfiguration as ObjectLockConfiguration,
|
||||
PersistedPublicAccessBlockConfiguration as PublicAccessBlockConfiguration, PersistedReplicationConfiguration,
|
||||
PersistedRequestPaymentConfiguration as RequestPaymentConfiguration,
|
||||
PersistedVersioningConfiguration as VersioningConfiguration, PersistedWebsiteConfiguration as WebsiteConfiguration,
|
||||
};
|
||||
use rustfs_policy::policy::BucketPolicy;
|
||||
use s3s::dto::{
|
||||
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, BucketVersioningStatus, CORSConfiguration,
|
||||
NotificationConfiguration, ObjectLockConfiguration, PublicAccessBlockConfiguration, ReplicationConfiguration,
|
||||
RequestPaymentConfiguration, ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration, WebsiteConfiguration,
|
||||
};
|
||||
use serde::Serializer;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
@@ -45,11 +40,23 @@ use time::{Date, OffsetDateTime, PrimitiveDateTime, Time as CivilTime, UtcOffset
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
// The persisted Object Lock configuration answers the storage-level trait
|
||||
// here because this module owns the persisted `ObjectLockConfiguration`.
|
||||
// The serving-layer DTO impls for the storage-level Object Lock traits live
|
||||
// here because this module owns the persisted `ObjectLockConfiguration`
|
||||
// during the s3s ratchet migration (rustfs/backlog#1842).
|
||||
impl ObjectLockApi for ObjectLockConfiguration {
|
||||
fn enabled(&self) -> bool {
|
||||
self.object_lock_enabled.as_deref() == Some("Enabled")
|
||||
self.object_lock_enabled
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.as_str() == s3s::dto::ObjectLockEnabled::ENABLED)
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectLockStatusExt for s3s::dto::ObjectLockLegalHoldStatus {
|
||||
fn valid(&self) -> bool {
|
||||
matches!(
|
||||
self.as_str(),
|
||||
s3s::dto::ObjectLockLegalHoldStatus::ON | s3s::dto::ObjectLockLegalHoldStatus::OFF
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,8 +439,6 @@ pub struct BucketMetadata {
|
||||
pub bucket_incarnation_id: Uuid,
|
||||
pub(crate) bucket_incarnation_sidecar: bool,
|
||||
pub(crate) bucket_creation_committed: bool,
|
||||
// A legacy default is not proof of an interrupted creation.
|
||||
pub(crate) bucket_creation_commit_record_present: bool,
|
||||
pub policy_config_json: Vec<u8>,
|
||||
pub notification_config_xml: Vec<u8>,
|
||||
pub lifecycle_config_xml: Vec<u8>,
|
||||
@@ -482,13 +487,13 @@ pub struct BucketMetadata {
|
||||
|
||||
pub policy_config: Option<BucketPolicy>,
|
||||
pub notification_config: Option<NotificationConfiguration>,
|
||||
pub lifecycle_config: Option<PersistedLifecycleConfiguration>,
|
||||
pub lifecycle_config: Option<BucketLifecycleConfiguration>,
|
||||
pub object_lock_config: Option<ObjectLockConfiguration>,
|
||||
pub versioning_config: Option<VersioningConfiguration>,
|
||||
pub sse_config: Option<ServerSideEncryptionConfiguration>,
|
||||
pub tagging_config: Option<Tagging>,
|
||||
pub quota_config: Option<BucketQuota>,
|
||||
pub replication_config: Option<PersistedReplicationConfiguration>,
|
||||
pub replication_config: Option<ReplicationConfiguration>,
|
||||
pub bucket_target_config: Option<BucketTargets>,
|
||||
pub bucket_target_config_meta: Option<HashMap<String, String>>,
|
||||
pub cors_config: Option<CORSConfiguration>,
|
||||
@@ -509,7 +514,6 @@ impl Default for BucketMetadata {
|
||||
bucket_incarnation_id: Uuid::nil(),
|
||||
bucket_incarnation_sidecar: false,
|
||||
bucket_creation_committed: false,
|
||||
bucket_creation_commit_record_present: false,
|
||||
policy_config_json: Default::default(),
|
||||
notification_config_xml: Default::default(),
|
||||
lifecycle_config_xml: Default::default(),
|
||||
@@ -748,10 +752,7 @@ impl BucketMetadata {
|
||||
"TableBucketConfigUpdatedAt" => self.table_bucket_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"DurabilityConfigUpdatedAt" => self.durability_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"OnDemandMigrationConfigUpdatedAt" => self.on_demand_migration_config_updated_at = read_msgp_time_value(rd)?,
|
||||
"BucketCreationCommitted" => {
|
||||
self.bucket_creation_committed = read_msgp_bool(rd)?;
|
||||
self.bucket_creation_commit_record_present = true;
|
||||
}
|
||||
"BucketCreationCommitted" => self.bucket_creation_committed = read_msgp_bool(rd)?,
|
||||
other => {
|
||||
tracing::debug!(field = %other, "BucketMetadata decode_from: skipping unknown field");
|
||||
skip_msgp_value(rd)?;
|
||||
@@ -764,10 +765,8 @@ impl BucketMetadata {
|
||||
|
||||
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
|
||||
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
|
||||
// Preserve the absence of a legacy commit record across config rewrites.
|
||||
let commit_record_present = self.bucket_creation_commit_record_present || self.bucket_creation_committed;
|
||||
// Map size: MinIO fields (25) + RustFS extensions (21) + optional commit record.
|
||||
let map_len: u32 = 46 + u32::from(commit_record_present);
|
||||
// Map size: MinIO fields (25) + RustFS extensions (22)
|
||||
let map_len: u32 = 47;
|
||||
rmp::encode::write_map_len(wr, map_len)?;
|
||||
|
||||
// MinIO field order (same as Go struct)
|
||||
@@ -848,10 +847,8 @@ impl BucketMetadata {
|
||||
write_msgp_time(wr, self.durability_config_updated_at)?;
|
||||
rmp::encode::write_str(wr, "OnDemandMigrationConfigUpdatedAt")?;
|
||||
write_msgp_time(wr, self.on_demand_migration_config_updated_at)?;
|
||||
if commit_record_present {
|
||||
rmp::encode::write_str(wr, "BucketCreationCommitted")?;
|
||||
rmp::encode::write_bool(wr, self.bucket_creation_committed)?;
|
||||
}
|
||||
rmp::encode::write_str(wr, "BucketCreationCommitted")?;
|
||||
rmp::encode::write_bool(wr, self.bucket_creation_committed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1021,11 +1018,9 @@ impl BucketMetadata {
|
||||
None
|
||||
} else {
|
||||
let config = deserialize::<VersioningConfiguration>(&data)?;
|
||||
if config
|
||||
.status
|
||||
.as_ref()
|
||||
.is_some_and(|status| !matches!(status.as_str(), "Enabled" | "Suspended"))
|
||||
{
|
||||
if config.status.as_ref().is_some_and(|status| {
|
||||
!matches!(status.as_str(), BucketVersioningStatus::ENABLED | BucketVersioningStatus::SUSPENDED)
|
||||
}) {
|
||||
return Err(Error::other("bucket versioning configuration has an invalid status"));
|
||||
}
|
||||
Some(config)
|
||||
@@ -1038,7 +1033,7 @@ impl BucketMetadata {
|
||||
let config = if data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let config = deserialize::<PersistedReplicationConfiguration>(&data)?;
|
||||
let config = deserialize::<ReplicationConfiguration>(&data)?;
|
||||
if let Some(field) = invalid_replication_config_status_field(&config) {
|
||||
return Err(Error::other(format!("replication field {field} has an invalid status")));
|
||||
}
|
||||
@@ -1232,8 +1227,8 @@ impl BucketMetadata {
|
||||
}
|
||||
self.lifecycle_config = None;
|
||||
if !self.lifecycle_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<PersistedLifecycleConfiguration>(&self.lifecycle_config_xml)
|
||||
.map(|c| self.lifecycle_config = Some(c))
|
||||
&& let Err(e) =
|
||||
deserialize::<BucketLifecycleConfiguration>(&self.lifecycle_config_xml).map(|c| self.lifecycle_config = Some(c))
|
||||
{
|
||||
tracing::warn!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
@@ -1319,8 +1314,8 @@ impl BucketMetadata {
|
||||
}
|
||||
self.replication_config = None;
|
||||
if !self.replication_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<PersistedReplicationConfiguration>(&self.replication_config_xml)
|
||||
.map(|c| self.replication_config = Some(c))
|
||||
&& let Err(e) =
|
||||
deserialize::<ReplicationConfiguration>(&self.replication_config_xml).map(|c| self.replication_config = Some(c))
|
||||
{
|
||||
tracing::warn!(
|
||||
event = "bucket_metadata_parse_failed",
|
||||
@@ -1696,13 +1691,6 @@ mod test {
|
||||
let mut bm = BucketMetadata::unmarshal(&body[4..]).expect("unmarshal recovered blob");
|
||||
assert_eq!(bm.name, "interop");
|
||||
assert!(!bm.bucket_creation_committed, "legacy metadata must default to uncommitted");
|
||||
assert!(!bm.bucket_creation_commit_record_present, "legacy metadata has no creation-commit record");
|
||||
let rewritten = BucketMetadata::unmarshal(&bm.marshal_msg().expect("rewrite legacy metadata"))
|
||||
.expect("read rewritten legacy metadata");
|
||||
assert!(
|
||||
!rewritten.bucket_creation_commit_record_present,
|
||||
"rewriting legacy metadata must not fabricate a creation intent"
|
||||
);
|
||||
bm.parse_all_configs().expect("parse recovered configs");
|
||||
assert!(bm.lifecycle_config.is_some());
|
||||
}
|
||||
@@ -1720,7 +1708,6 @@ mod test {
|
||||
|
||||
assert_eq!(bm.name, new.name);
|
||||
assert!(new.bucket_creation_committed);
|
||||
assert!(new.bucket_creation_commit_record_present);
|
||||
assert!(!bm.bucket_incarnation_id.is_nil());
|
||||
assert_eq!(bm.bucket_incarnation_id, new.bucket_incarnation_id);
|
||||
}
|
||||
@@ -1825,15 +1812,17 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn object_locking_requires_lock_metadata_not_plain_versioning() {
|
||||
use s3s::dto::ObjectLockEnabled;
|
||||
|
||||
let mut bm = BucketMetadata::new("test-bucket");
|
||||
bm.versioning_config = Some(VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(s3s::dto::BucketVersioningStatus::from_static("Enabled")),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!bm.object_locking());
|
||||
|
||||
bm.object_lock_config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(bm.object_locking());
|
||||
|
||||
@@ -30,18 +30,14 @@ use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use crate::store::{ECStore, await_bucket_namespace_operation};
|
||||
use futures::future::join_all;
|
||||
use rustfs_gateway_types::cors_tagging::{PersistedCorsConfiguration as CORSConfiguration, PersistedTagging as Tagging};
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedAccelerateConfiguration as AccelerateConfiguration,
|
||||
PersistedBucketEncryptionConfiguration as ServerSideEncryptionConfiguration,
|
||||
PersistedBucketLoggingStatus as BucketLoggingStatus, PersistedLifecycleConfiguration,
|
||||
PersistedNotificationConfiguration as NotificationConfiguration, PersistedObjectLockConfiguration as ObjectLockConfiguration,
|
||||
PersistedPublicAccessBlockConfiguration as PublicAccessBlockConfiguration, PersistedReplicationConfiguration,
|
||||
PersistedRequestPaymentConfiguration as RequestPaymentConfiguration,
|
||||
PersistedVersioningConfiguration as VersioningConfiguration, PersistedWebsiteConfiguration as WebsiteConfiguration,
|
||||
};
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
use rustfs_policy::policy::BucketPolicy;
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
use s3s::dto::{
|
||||
AccelerateConfiguration, BucketLifecycleConfiguration, BucketLoggingStatus, CORSConfiguration, NotificationConfiguration,
|
||||
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, PublicAccessBlockConfiguration,
|
||||
RequestPaymentConfiguration, ServerSideEncryptionConfiguration, Tagging, VersioningConfiguration, WebsiteConfiguration,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
@@ -172,7 +168,7 @@ pub(crate) fn object_lock_config_state_from_authoritative_metadata(bm: &BucketMe
|
||||
if bm.lock_enabled {
|
||||
return Ok(ObjectLockConfigState::Configured {
|
||||
config: ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
},
|
||||
updated_at: bm.object_lock_config_updated_at,
|
||||
@@ -216,10 +212,17 @@ pub(crate) fn configured_object_lock_state_for_tests(
|
||||
) -> ObjectLockConfigState {
|
||||
ObjectLockConfigState::Configured {
|
||||
config: ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
rule: Some(rustfs_gateway_types::persistence::PersistedObjectLockRule {
|
||||
default_retention: Some(rustfs_gateway_types::persistence::PersistedDefaultRetention {
|
||||
mode: Some(mode.as_str().to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(s3s::dto::ObjectLockRule {
|
||||
default_retention: Some(s3s::dto::DefaultRetention {
|
||||
mode: Some(s3s::dto::ObjectLockRetentionMode::from_static(match mode {
|
||||
crate::bucket::object_lock::types::RetentionMode::Governance => {
|
||||
s3s::dto::ObjectLockRetentionMode::GOVERNANCE
|
||||
}
|
||||
crate::bucket::object_lock::types::RetentionMode::Compliance => {
|
||||
s3s::dto::ObjectLockRetentionMode::COMPLIANCE
|
||||
}
|
||||
})),
|
||||
days: Some(days),
|
||||
years: None,
|
||||
}),
|
||||
@@ -230,7 +233,7 @@ pub(crate) fn configured_object_lock_state_for_tests(
|
||||
}
|
||||
|
||||
fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -> Result<()> {
|
||||
if config.object_lock_enabled.as_deref() != Some("Enabled") {
|
||||
if config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str) != Some(ObjectLockEnabled::ENABLED) {
|
||||
return Err(Error::other("persisted bucket Object Lock enabled state is invalid"));
|
||||
}
|
||||
|
||||
@@ -243,7 +246,7 @@ fn validate_authoritative_object_lock_config(config: &ObjectLockConfiguration) -
|
||||
if !retention
|
||||
.mode
|
||||
.as_ref()
|
||||
.is_some_and(|mode| matches!(mode.as_str(), "COMPLIANCE" | "GOVERNANCE"))
|
||||
.is_some_and(|mode| matches!(mode.as_str(), ObjectLockRetentionMode::COMPLIANCE | ObjectLockRetentionMode::GOVERNANCE))
|
||||
{
|
||||
return Err(Error::other("persisted bucket Object Lock retention mode is invalid"));
|
||||
}
|
||||
@@ -1365,7 +1368,7 @@ pub async fn get_public_access_block_config(bucket: &str) -> Result<(PublicAcces
|
||||
bucket_meta_sys.get_public_access_block_config(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_lifecycle_config(bucket: &str) -> Result<(PersistedLifecycleConfiguration, OffsetDateTime)> {
|
||||
pub async fn get_lifecycle_config(bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
@@ -1584,7 +1587,7 @@ pub(crate) async fn get_cached_quota_config_and_incarnation_in(
|
||||
bucket_meta_sys.cached_quota_config_and_incarnation(bucket).await
|
||||
}
|
||||
|
||||
pub async fn get_replication_config(bucket: &str) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
|
||||
pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
@@ -1594,7 +1597,7 @@ pub async fn get_replication_config(bucket: &str) -> Result<(PersistedReplicatio
|
||||
pub(crate) async fn get_replication_config_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
|
||||
) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||
|
||||
@@ -1720,27 +1723,9 @@ struct MetadataPublishGuard {
|
||||
_guard: tokio::sync::OwnedMutexGuard<MetadataPublishLockState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingBucketCreationProof {
|
||||
pub(crate) generation: Uuid,
|
||||
pub(crate) intent: Vec<u8>,
|
||||
pub(crate) versioning_requested: bool,
|
||||
pub(crate) witnesses: Vec<crate::disk::os::NativeBucketCreationWitness>,
|
||||
}
|
||||
|
||||
impl PendingBucketCreationProof {
|
||||
pub(crate) fn matches_metadata(&self, metadata: &BucketMetadata) -> Result<bool> {
|
||||
Ok(!metadata.bucket_creation_committed
|
||||
&& metadata.bucket_incarnation_sidecar
|
||||
&& metadata.bucket_incarnation_id == self.generation
|
||||
&& metadata.marshal_msg()? == self.intent)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BucketMetadataSys {
|
||||
metadata_map: Arc<RwLock<HashMap<String, Arc<BucketMetadata>>>>,
|
||||
pending_bucket_creations: Arc<RwLock<HashMap<String, PendingBucketCreationProof>>>,
|
||||
/// Serializes metadata-map commits and their derived cache updates for one
|
||||
/// bucket. Namespace locks, when present, are acquired before this lock.
|
||||
metadata_publish_locks: Arc<MetadataPublishLockRegistry>,
|
||||
@@ -1784,7 +1769,6 @@ impl BucketMetadataSys {
|
||||
pub fn new(api: Arc<ECStore>) -> Self {
|
||||
Self {
|
||||
metadata_map: Arc::new(RwLock::new(HashMap::new())),
|
||||
pending_bucket_creations: Arc::new(RwLock::new(HashMap::new())),
|
||||
metadata_publish_locks: Arc::new(MetadataPublishLockRegistry {
|
||||
locks: StdMutex::new(HashMap::new()),
|
||||
}),
|
||||
@@ -1815,81 +1799,6 @@ impl BucketMetadataSys {
|
||||
.expect("bucket metadata object store should still be live")
|
||||
}
|
||||
|
||||
pub(crate) async fn pending_bucket_creation(&self, bucket: &str) -> Option<PendingBucketCreationProof> {
|
||||
self.pending_bucket_creations.read().await.get(bucket).cloned()
|
||||
}
|
||||
|
||||
pub(crate) async fn grant_pending_bucket_creation(&self, bucket: &str, proof: PendingBucketCreationProof) {
|
||||
self.pending_bucket_creations.write().await.insert(bucket.to_owned(), proof);
|
||||
}
|
||||
|
||||
pub(crate) async fn revoke_pending_bucket_creation(&self, bucket: &str) {
|
||||
self.pending_bucket_creations.write().await.remove(bucket);
|
||||
}
|
||||
|
||||
pub(crate) async fn consume_pending_bucket_creation(
|
||||
&self,
|
||||
bucket: &str,
|
||||
metadata: &BucketMetadata,
|
||||
witnesses: &[crate::disk::os::NativeBucketCreationWitness],
|
||||
) -> Result<bool> {
|
||||
let mut pending = self.pending_bucket_creations.write().await;
|
||||
let Some(proof) = pending.get(bucket) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !proof.matches_metadata(metadata)?
|
||||
|| proof.witnesses.len() != witnesses.len()
|
||||
|| !proof
|
||||
.witnesses
|
||||
.iter()
|
||||
.zip(witnesses)
|
||||
.all(|(old, current)| old.matches(current))
|
||||
|| !crate::disk::os::consume_native_creation_witnesses(witnesses)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
pending.remove(bucket);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn refresh_bucket_for_test(&self, bucket: &str) -> Result<()> {
|
||||
let expected = self.metadata_map.read().await.get(bucket).cloned();
|
||||
let lock = self.object_store().new_ns_lock(bucket, bucket).await?;
|
||||
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||
self.load_bucket_under_namespace(bucket, MetadataLoadMode::Refresh, expected.as_ref(), &guard)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn publish_persisted_metadata_for_test(&self, bucket: &str) -> Result<()> {
|
||||
let expected = self.metadata_map.read().await.get(bucket).cloned();
|
||||
let lock = self.object_store().new_ns_lock(bucket, bucket).await?;
|
||||
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||
let (metadata, persisted) = await_bucket_namespace_operation(
|
||||
Some(&guard),
|
||||
bucket,
|
||||
"test cached metadata load",
|
||||
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
|
||||
)
|
||||
.await?;
|
||||
self.publish_if_unchanged(bucket, expected.as_ref(), metadata, persisted, &guard)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn migrate_bucket_creation_commit_for_test(&self, bucket: &str) -> Result<()> {
|
||||
self.migrate_bucket_creation_commit(bucket).await.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn legacy_migration_reports_missing_bucket_for_test(&self, bucket: &str) -> Result<bool> {
|
||||
Ok(matches!(
|
||||
self.migrate_legacy_metadata(bucket).await?,
|
||||
BucketMetadataAuthority::MissingBucket
|
||||
))
|
||||
}
|
||||
|
||||
fn object_store_if_live(&self) -> Option<Arc<ECStore>> {
|
||||
self.api.upgrade()
|
||||
}
|
||||
@@ -2080,7 +1989,6 @@ impl BucketMetadataSys {
|
||||
expected: Option<&Arc<BucketMetadata>>,
|
||||
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) -> Result<()> {
|
||||
self.revoke_pending_bucket_creation(bucket).await;
|
||||
if !await_bucket_namespace_operation(
|
||||
Some(namespace_guard),
|
||||
bucket,
|
||||
@@ -2164,7 +2072,6 @@ impl BucketMetadataSys {
|
||||
persisted: bool,
|
||||
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) -> Result<()> {
|
||||
self.revoke_pending_bucket_creation(bucket).await;
|
||||
if !persisted {
|
||||
let _publish_guard = self
|
||||
.lock_metadata_publish(bucket, namespace_guard, "refreshed bucket metadata absence publish")
|
||||
@@ -2226,7 +2133,6 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
pub async fn set(&self, bucket: String, bm: Arc<BucketMetadata>) {
|
||||
self.revoke_pending_bucket_creation(&bucket).await;
|
||||
if !is_meta_bucketname(&bucket) {
|
||||
let publish_lock = self.metadata_publish_lock(&bucket);
|
||||
let _publish_guard = publish_lock.lock().await;
|
||||
@@ -2248,7 +2154,6 @@ impl BucketMetadataSys {
|
||||
if is_meta_bucketname(bucket) {
|
||||
return false;
|
||||
}
|
||||
self.revoke_pending_bucket_creation(bucket).await;
|
||||
let publish_lock = self.metadata_publish_lock(bucket);
|
||||
let _publish_guard = publish_lock.lock().await;
|
||||
let mut map = self.metadata_map.write().await;
|
||||
@@ -2266,7 +2171,6 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
async fn _reset(&mut self) {
|
||||
self.pending_bucket_creations.write().await.clear();
|
||||
let mut map = self.metadata_map.write().await;
|
||||
map.clear();
|
||||
drop(map);
|
||||
@@ -2405,7 +2309,6 @@ impl BucketMetadataSys {
|
||||
/// server's metadata never leaks into the ambient (first) instance.
|
||||
pub(crate) async fn persist_and_set(&self, bm: BucketMetadata) -> Result<()> {
|
||||
let mut bm = bm;
|
||||
self.revoke_pending_bucket_creation(&bm.name).await;
|
||||
bm.save_with_store(self.object_store()).await?;
|
||||
|
||||
self.set(bm.name.clone(), Arc::new(bm)).await;
|
||||
@@ -2414,7 +2317,6 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
async fn persist_new_and_set(&self, mut bm: BucketMetadata) -> Result<()> {
|
||||
self.revoke_pending_bucket_creation(&bm.name).await;
|
||||
bm.bucket_creation_committed = true;
|
||||
bm.save_with_store_committed(self.object_store()).await?;
|
||||
save_bucket_incarnation(self.object_store(), &bm.name, bm.bucket_incarnation_id).await?;
|
||||
@@ -2424,9 +2326,7 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
async fn persist_new_bucket_metadata_intent(&self, mut bm: BucketMetadata) -> Result<()> {
|
||||
self.revoke_pending_bucket_creation(&bm.name).await;
|
||||
bm.bucket_creation_committed = false;
|
||||
bm.bucket_creation_commit_record_present = true;
|
||||
bm.save_with_store(self.object_store()).await?;
|
||||
save_bucket_incarnation(self.object_store(), &bm.name, bm.bucket_incarnation_id).await?;
|
||||
bm.bucket_incarnation_sidecar = true;
|
||||
@@ -2436,7 +2336,6 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
async fn commit_bucket_metadata(&self, mut bm: BucketMetadata) -> Result<()> {
|
||||
self.revoke_pending_bucket_creation(&bm.name).await;
|
||||
bm.parse_all_configs()?;
|
||||
bm.bucket_incarnation_sidecar = true;
|
||||
// The caller holds the full bucket creation fence (lifecycle, metadata
|
||||
@@ -2485,7 +2384,6 @@ impl BucketMetadataSys {
|
||||
bucket: &str,
|
||||
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) -> Result<()> {
|
||||
self.revoke_pending_bucket_creation(bucket).await;
|
||||
let expected = self.metadata_map.read().await.get(bucket).cloned();
|
||||
if !self
|
||||
.bucket_exists(bucket, namespace_guard, "peer bucket metadata existence check")
|
||||
@@ -2542,7 +2440,6 @@ impl BucketMetadataSys {
|
||||
#[cfg(test)]
|
||||
self.lazy_disk_loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
self.revoke_pending_bucket_creation(bucket).await;
|
||||
let lock = self.object_store().new_ns_lock(bucket, bucket).await?;
|
||||
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||
#[cfg(test)]
|
||||
@@ -2868,7 +2765,6 @@ impl BucketMetadataSys {
|
||||
/// revalidated at write quorum, so a stale snapshot can neither revert an
|
||||
/// acknowledged configuration update nor outlive a delete/recreate.
|
||||
async fn migrate_bucket_creation_commit(&self, bucket: &str) -> Result<BucketMetadataAuthority> {
|
||||
self.revoke_pending_bucket_creation(bucket).await;
|
||||
let transaction_lock = self
|
||||
.object_store()
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
|
||||
@@ -2946,7 +2842,6 @@ impl BucketMetadataSys {
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<BucketMetadataAuthority> {
|
||||
self.revoke_pending_bucket_creation(bucket).await;
|
||||
#[cfg(test)]
|
||||
if self.object_lock_disk_read_errors.write().await.remove(bucket) {
|
||||
return Err(Error::other(format!("injected Object Lock metadata disk read failure: {bucket}")));
|
||||
@@ -3122,7 +3017,7 @@ impl BucketMetadataSys {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_lifecycle_config(&self, bucket: &str) -> Result<(PersistedLifecycleConfiguration, OffsetDateTime)> {
|
||||
pub async fn get_lifecycle_config(&self, bucket: &str) -> Result<(BucketLifecycleConfiguration, OffsetDateTime)> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
match ConfigState::of(&bm.lifecycle_config_xml, &bm.lifecycle_config).require(bucket, BUCKET_LIFECYCLE_CONFIG)? {
|
||||
@@ -3245,7 +3140,7 @@ impl BucketMetadataSys {
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_replication_config(&self, bucket: &str) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
|
||||
pub async fn get_replication_config(&self, bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
let (bm, _) = self.get_config(bucket).await?;
|
||||
|
||||
if !bm.replication_config_xml.is_empty() && bm.replication_config.is_none() {
|
||||
@@ -3339,7 +3234,6 @@ mod tests {
|
||||
};
|
||||
use crate::bucket::target::{BucketTarget, BucketTargetType, Credentials};
|
||||
use crate::config::com::read_config;
|
||||
use crate::disk::{DiskAPI as _, RUSTFS_META_TMP_BUCKET};
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
|
||||
use byteorder::{ByteOrder as _, LittleEndian};
|
||||
use serial_test::serial;
|
||||
@@ -3394,180 +3288,6 @@ mod tests {
|
||||
),
|
||||
];
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(storage_class_env)]
|
||||
async fn persistence_entry_failures_revoke_pending_creation_before_cache_publish() {
|
||||
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("instance metadata system");
|
||||
let sys = sys.read().await.clone();
|
||||
let options = MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
for entry in ["save", "persist", "persist-new", "commit"] {
|
||||
let bucket = format!("revoke-{entry}-{}", Uuid::new_v4());
|
||||
store.fail_next_bucket_creation_after_intent_for_test(&bucket);
|
||||
store
|
||||
.make_bucket(&bucket, &options)
|
||||
.await
|
||||
.expect_err("stop the real producer before physical publication");
|
||||
sys.pending_bucket_creation(&bucket)
|
||||
.await
|
||||
.expect("the producer must leave a real live creation permission");
|
||||
let (intent, persisted) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("read the original producer intent");
|
||||
assert!(persisted, "{entry}: the complete original intent must be persisted");
|
||||
let original_intent = intent.marshal_msg().expect("encode the original intent");
|
||||
|
||||
// Keep the creation fence order used by the real producer.
|
||||
let lifecycle_guard = store
|
||||
.acquire_bucket_lifecycle_write_lock(&bucket)
|
||||
.await
|
||||
.expect("hold the bucket lifecycle fence");
|
||||
let transaction_guard = acquire_bucket_metadata_transaction_lock_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("hold the metadata transaction fence");
|
||||
let namespace_lock = store
|
||||
.new_ns_lock(&bucket, &bucket)
|
||||
.await
|
||||
.expect("create the exact bucket namespace lock");
|
||||
let namespace_guard = namespace_lock
|
||||
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("hold the exact bucket namespace fence");
|
||||
|
||||
// A file at each native staging root causes actual IO to fail before
|
||||
// metadata can reach the cache-publishing set call. Preserve the
|
||||
// original staging directories so no fixture data is discarded.
|
||||
for dir in &dirs {
|
||||
let staging = dir.path().join(RUSTFS_META_TMP_BUCKET);
|
||||
let saved = dir.path().join(".rustfs.sys/revocation-saved-tmp");
|
||||
std::fs::rename(&staging, &saved).expect("preserve the native staging directory");
|
||||
std::fs::write(&staging, b"block metadata staging with a non-directory")
|
||||
.expect("install an actual non-directory IO obstruction");
|
||||
}
|
||||
let result = match entry {
|
||||
"save" => sys.save(intent).await,
|
||||
"persist" => sys.persist_and_set(intent).await,
|
||||
"persist-new" => sys.persist_new_and_set(intent).await,
|
||||
"commit" => sys.commit_bucket_metadata(intent).await,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
for dir in &dirs {
|
||||
let staging = dir.path().join(RUSTFS_META_TMP_BUCKET);
|
||||
let saved = dir.path().join(".rustfs.sys/revocation-saved-tmp");
|
||||
std::fs::remove_file(&staging).expect("remove only the fixture's staging obstruction");
|
||||
std::fs::rename(&saved, &staging).expect("restore the complete original staging directory");
|
||||
}
|
||||
drop((namespace_guard, transaction_guard, lifecycle_guard));
|
||||
result.expect_err("native persistence must fail before its downstream cache set");
|
||||
assert!(
|
||||
sys.pending_bucket_creation(&bucket).await.is_none(),
|
||||
"{entry}: the entry revoker must retire live permission even when persistence fails"
|
||||
);
|
||||
let (after_failure, present) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("read the intent after the failed persistence entry");
|
||||
assert!(present, "{entry}: a failed persistence entry must retain the original metadata");
|
||||
assert_eq!(
|
||||
after_failure
|
||||
.marshal_msg()
|
||||
.expect("encode the original intent after persistence failure"),
|
||||
original_intent,
|
||||
"{entry}: native IO failure must not change the original generation or complete intent"
|
||||
);
|
||||
let error = store
|
||||
.make_bucket(&bucket, &options)
|
||||
.await
|
||||
.expect_err("a failed ordinary persistence entry cannot preserve creation permission");
|
||||
assert!(error.to_string().contains("administrator recovery"), "{entry}: {error}");
|
||||
let (after_retry, _) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("read the original intent after the rejected retry");
|
||||
assert_eq!(
|
||||
after_retry.marshal_msg().expect("encode the intent after the rejected retry"),
|
||||
original_intent,
|
||||
"{entry}: the rejected retry must preserve the complete original intent"
|
||||
);
|
||||
for disk in store.pools[0].disk_set[0].disks.read().await.iter() {
|
||||
assert!(
|
||||
matches!(
|
||||
disk.as_ref().expect("native disk").stat_volume(&bucket).await,
|
||||
Err(crate::disk::error::DiskError::VolumeNotFound)
|
||||
),
|
||||
"{entry}: failed persistence and the rejected retry must not publish a physical volume"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(storage_class_env)]
|
||||
async fn metadata_reset_revokes_real_pending_creation_without_changing_persisted_intent() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("revoke-reset-{}", Uuid::new_v4());
|
||||
let options = MakeBucketOptions {
|
||||
lock_enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
store.fail_next_bucket_creation_after_intent_for_test(&bucket);
|
||||
store
|
||||
.make_bucket(&bucket, &options)
|
||||
.await
|
||||
.expect_err("stop the real producer before physical publication");
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("instance metadata system");
|
||||
let mut sys = sys.read().await.clone();
|
||||
sys.pending_bucket_creation(&bucket)
|
||||
.await
|
||||
.expect("the real producer must leave live creation permission before reset");
|
||||
let (intent, persisted) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("read the original intent before cache reset");
|
||||
assert!(persisted, "cache reset starts from a complete real persisted intent");
|
||||
let original_intent = intent.marshal_msg().expect("encode the original reset intent");
|
||||
sys._reset().await;
|
||||
assert!(
|
||||
sys.pending_bucket_creation(&bucket).await.is_none(),
|
||||
"reset must revoke the producer-issued permission shared with the instance metadata system"
|
||||
);
|
||||
let (after_reset, present) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("read the original intent after cache reset");
|
||||
assert!(present, "cache reset must retain the persisted intent");
|
||||
assert_eq!(
|
||||
after_reset.marshal_msg().expect("encode the intent after cache reset"),
|
||||
original_intent,
|
||||
"cache reset must leave the original generation and complete persisted intent unchanged"
|
||||
);
|
||||
let error = store
|
||||
.make_bucket(&bucket, &options)
|
||||
.await
|
||||
.expect_err("reset cannot regrant the producer's old creation permission");
|
||||
assert!(error.to_string().contains("administrator recovery"), "{error}");
|
||||
let (after_retry, _) = get_config_from_disk_with_presence_in(&store.ctx, &bucket)
|
||||
.await
|
||||
.expect("read the intent after the reset-induced retry rejection");
|
||||
assert_eq!(
|
||||
after_retry
|
||||
.marshal_msg()
|
||||
.expect("encode the intent after the rejected reset retry"),
|
||||
original_intent,
|
||||
"the reset-induced rejection must preserve the original complete intent"
|
||||
);
|
||||
for disk in store.pools[0].disk_set[0].disks.read().await.iter() {
|
||||
assert!(
|
||||
matches!(
|
||||
disk.as_ref().expect("native disk").stat_volume(&bucket).await,
|
||||
Err(crate::disk::error::DiskError::VolumeNotFound)
|
||||
),
|
||||
"cache reset and the rejected retry must not publish a physical volume"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn g_d3_003_new_writer_replication_loads_without_fail_closed_state() {
|
||||
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||
@@ -3756,7 +3476,7 @@ mod tests {
|
||||
let mut versioned = BucketMetadata::new("authoritative-versioned");
|
||||
versioned.versioning_config_xml = b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec();
|
||||
versioned.versioning_config = Some(VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
});
|
||||
sys.set("authoritative-versioned".to_string(), Arc::new(versioned)).await;
|
||||
@@ -3879,24 +3599,25 @@ mod tests {
|
||||
else {
|
||||
panic!("legacy lock-enabled metadata must remain configured");
|
||||
};
|
||||
assert_eq!(config.object_lock_enabled.as_deref(), Some("Enabled"));
|
||||
assert_eq!(
|
||||
config.object_lock_enabled.as_ref().map(|value| value.as_str()),
|
||||
Some(ObjectLockEnabled::ENABLED)
|
||||
);
|
||||
assert!(config.rule.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_object_lock_state_rejects_invalid_retention_periods() {
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedDefaultRetention as DefaultRetention, PersistedObjectLockRule as ObjectLockRule,
|
||||
};
|
||||
use s3s::dto::{DefaultRetention, ObjectLockRule};
|
||||
|
||||
for (days, years) in [(Some(0), None), (Some(-1), None), (Some(1), Some(1)), (None, None)] {
|
||||
let mut metadata = BucketMetadata::new("invalid-object-lock");
|
||||
metadata.object_lock_config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
days,
|
||||
mode: Some("COMPLIANCE".to_owned()),
|
||||
mode: Some(ObjectLockRetentionMode::from_static(ObjectLockRetentionMode::COMPLIANCE)),
|
||||
years,
|
||||
}),
|
||||
}),
|
||||
@@ -3911,11 +3632,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn authoritative_object_lock_state_rejects_invalid_enabled_mode_and_rule() {
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedDefaultRetention as DefaultRetention, PersistedObjectLockRule as ObjectLockRule,
|
||||
};
|
||||
use s3s::dto::{DefaultRetention, ObjectLockRule};
|
||||
|
||||
for enabled in [None, Some("Disabled".to_string())] {
|
||||
for enabled in [None, Some(ObjectLockEnabled::from("Disabled".to_string()))] {
|
||||
let mut metadata = BucketMetadata::new("invalid-object-lock-enabled");
|
||||
metadata.lock_enabled = true;
|
||||
metadata.object_lock_config = Some(ObjectLockConfiguration {
|
||||
@@ -3927,11 +3646,11 @@ mod tests {
|
||||
|
||||
let mut invalid_mode = BucketMetadata::new("invalid-object-lock-mode");
|
||||
invalid_mode.object_lock_config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule {
|
||||
default_retention: Some(DefaultRetention {
|
||||
days: Some(1),
|
||||
mode: Some("invalid".to_string()),
|
||||
mode: Some(ObjectLockRetentionMode::from("invalid".to_string())),
|
||||
years: None,
|
||||
}),
|
||||
}),
|
||||
@@ -3940,7 +3659,7 @@ mod tests {
|
||||
|
||||
let mut missing_retention = BucketMetadata::new("missing-object-lock-retention");
|
||||
missing_retention.object_lock_config = Some(ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: Some(ObjectLockRule { default_retention: None }),
|
||||
});
|
||||
assert!(object_lock_config_state_from_authoritative_metadata(&missing_retention).is_err());
|
||||
@@ -3950,7 +3669,7 @@ mod tests {
|
||||
fn object_lock_ever_enabled_marker_survives_config_removal() {
|
||||
let mut metadata = BucketMetadata::new("lock-config-removed");
|
||||
let config = ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
};
|
||||
metadata
|
||||
@@ -3968,7 +3687,10 @@ mod tests {
|
||||
else {
|
||||
panic!("an ever-enabled bucket must remain Object Lock configured");
|
||||
};
|
||||
assert_eq!(config.object_lock_enabled.as_deref(), Some("Enabled"));
|
||||
assert_eq!(
|
||||
config.object_lock_enabled.as_ref().map(ObjectLockEnabled::as_str),
|
||||
Some(ObjectLockEnabled::ENABLED)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3984,7 +3706,7 @@ mod tests {
|
||||
|
||||
let node = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore.clone())));
|
||||
let config = ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
};
|
||||
update_with_sys(
|
||||
@@ -4965,7 +4687,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn update_config_with_persists_tagging_rewrite_across_disk_reload() {
|
||||
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
|
||||
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
|
||||
use s3s::dto::Tag;
|
||||
|
||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
|
||||
@@ -5129,7 +4851,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn concurrent_update_config_with_calls_do_not_lose_writes() {
|
||||
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
|
||||
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
|
||||
use s3s::dto::Tag;
|
||||
|
||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
||||
@@ -5229,7 +4951,7 @@ mod tests {
|
||||
let mut persisted = BucketMetadata::new("reload-bucket");
|
||||
persisted.policy_config_json = b"persisted-marker".to_vec();
|
||||
let lock_config = ObjectLockConfiguration {
|
||||
object_lock_enabled: Some("Enabled".to_owned()),
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
rule: None,
|
||||
};
|
||||
persisted
|
||||
@@ -5508,7 +5230,7 @@ mod tests {
|
||||
#[serial]
|
||||
async fn bucket_delete_waits_for_config_mutation_fence() {
|
||||
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
|
||||
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
|
||||
use s3s::dto::Tag;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
|
||||
@@ -17,8 +17,15 @@ pub mod objectlock_sys;
|
||||
pub mod types;
|
||||
|
||||
/// Whether a bucket Object Lock configuration has locking enabled. The
|
||||
/// persisted `ObjectLockConfiguration` implements this in the bucket-metadata
|
||||
/// module, which owns the persisted configuration type.
|
||||
/// serving-layer `ObjectLockConfiguration` DTO implements this in
|
||||
/// the bucket-metadata module, which owns the persisted configuration type
|
||||
/// during the s3s ratchet migration (rustfs/backlog#1842).
|
||||
pub trait ObjectLockApi {
|
||||
fn enabled(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Whether a legal-hold status value is one of the two valid wire values.
|
||||
/// Implemented for the serving-layer DTO in the bucket-metadata module.
|
||||
pub trait ObjectLockStatusExt {
|
||||
fn valid(&self) -> bool;
|
||||
}
|
||||
|
||||
@@ -235,6 +235,8 @@ impl QuotaChecker {
|
||||
Ok(updated_at)
|
||||
}
|
||||
|
||||
/// Return quota configuration with optional scanner usage, independently
|
||||
/// of the usage requirements enforced by write admission.
|
||||
pub async fn get_quota_stats(&self, bucket: &str) -> Result<(BucketQuota, Option<u64>), QuotaError> {
|
||||
// If bucket doesn't exist, return ConfigNotFound error
|
||||
if !self.bucket_exists(bucket).await {
|
||||
@@ -244,18 +246,13 @@ impl QuotaChecker {
|
||||
}
|
||||
|
||||
let quota = self.get_quota_config(bucket).await?;
|
||||
let current_usage = if quota.uses_durable_reservations() {
|
||||
self.report_only_usage(bucket).await
|
||||
} else {
|
||||
Some(self.get_real_time_usage(bucket).await?)
|
||||
};
|
||||
let current_usage = self.report_only_usage(bucket).await;
|
||||
|
||||
Ok((quota, current_usage))
|
||||
}
|
||||
|
||||
/// Scanner usage for a bucket the reservation ledger admits is reporting
|
||||
/// input only, so unavailable memory usage is reported as "unknown" and counted
|
||||
/// instead of becoming an error the write path would fail on.
|
||||
/// Best-effort usage for statistics and durable admission reports.
|
||||
/// Missing scanner coverage is unknown, not an error or a zero-sized bucket.
|
||||
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
|
||||
// The plain lookup: a report must not wait for an early snapshot reload.
|
||||
let usage = get_bucket_usage_memory(bucket).await;
|
||||
@@ -267,7 +264,7 @@ impl QuotaChecker {
|
||||
subsystem = LOG_SUBSYSTEM_QUOTA,
|
||||
state = "usage_unknown",
|
||||
bucket = %bucket,
|
||||
"durable quota usage report degraded"
|
||||
"quota usage report degraded"
|
||||
);
|
||||
}
|
||||
usage
|
||||
@@ -742,8 +739,12 @@ mod tests {
|
||||
1,
|
||||
"fail-closed admission takes the early-reload lookup"
|
||||
);
|
||||
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 2);
|
||||
let (_, usage) = checker
|
||||
.get_quota_stats(&legacy)
|
||||
.await
|
||||
.expect("legacy quota statistics are reporting only");
|
||||
assert_eq!(usage, None);
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 1);
|
||||
assert_eq!(
|
||||
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
|
||||
0,
|
||||
|
||||
@@ -245,3 +245,86 @@ async fn quota_without_limit_keeps_the_zero_usage_io_fast_path() {
|
||||
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket), 0);
|
||||
stop_reporting_fixture(&store).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_statistics_remain_available_without_a_complete_scanner_snapshot() {
|
||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||
|
||||
for configuration in ["unconfigured", "unlimited", "snapshot"] {
|
||||
for source in ["missing", "legacy", "incomplete", "observed", "complete", "empty"] {
|
||||
let (_dirs, store, _) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-statistics-{}", Uuid::new_v4().simple());
|
||||
match configuration {
|
||||
"unconfigured" => store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create bucket without a quota configuration"),
|
||||
"unlimited" => {
|
||||
create_bucket_with_quota_json(
|
||||
&store,
|
||||
&bucket,
|
||||
serde_json::to_vec(&BucketQuota::new(None)).expect("unlimited quota encoding"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"snapshot" => create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
// An unregistered checker keeps legacy migration out of the
|
||||
// assertion: reporting must work before a protocol upgrade.
|
||||
let metadata_sys = BucketMetadataSys::new(store.clone());
|
||||
let (metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
||||
metadata_sys.set(bucket.clone(), metadata).await;
|
||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
||||
let (snapshot_source, size) = if source == "empty" { ("complete", 0) } else { (source, 1000) };
|
||||
install_reporting_snapshot(store.clone(), &bucket, snapshot_source, size).await;
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
let statistics = checker.get_quota_stats(&bucket).await;
|
||||
let early_reloads = crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket);
|
||||
let missing_bucket = format!("missing-{}", Uuid::new_v4().simple());
|
||||
let missing_statistics = checker.get_quota_stats(&missing_bucket).await;
|
||||
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
|
||||
.await
|
||||
.expect("fixture cache cleanup");
|
||||
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
|
||||
let (quota, usage) = statistics.unwrap_or_else(|error| {
|
||||
panic!("configuration={configuration}, source={source}: quota statistics must remain available: {error}")
|
||||
});
|
||||
assert_eq!(quota.quota, (configuration == "snapshot").then_some(4096));
|
||||
assert_eq!(
|
||||
usage,
|
||||
(snapshot_source == "complete").then_some(size),
|
||||
"configuration={configuration}, source={source}: only known usage is numeric"
|
||||
);
|
||||
assert_eq!(early_reloads, 0, "read-only statistics must not force an early snapshot reload");
|
||||
assert!(
|
||||
matches!(missing_statistics, Err(QuotaError::ConfigNotFound { bucket }) if bucket == missing_bucket),
|
||||
"reporting must still reject a missing bucket"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_statistics_preserve_configuration_errors() {
|
||||
let (_dirs, store, _) = isolated_durable_quota_store().await;
|
||||
let bucket = format!("quota-invalid-{}", Uuid::new_v4().simple());
|
||||
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
|
||||
let metadata_sys = BucketMetadataSys::new(store.clone());
|
||||
let (mut metadata, _) = metadata_sys.get_config(&bucket).await.expect("load existing bucket metadata");
|
||||
Arc::make_mut(&mut metadata).quota_config_json = b"{not-json".to_vec();
|
||||
metadata_sys.set(bucket.clone(), metadata).await;
|
||||
let checker = QuotaChecker::new(Arc::new(RwLock::new(metadata_sys)));
|
||||
|
||||
let statistics = checker.get_quota_stats(&bucket).await;
|
||||
stop_reporting_fixture(&store).await;
|
||||
|
||||
assert!(
|
||||
matches!(statistics, Err(QuotaError::InvalidConfig { .. })),
|
||||
"unavailable reporting must not hide malformed quota configuration"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -707,13 +707,6 @@ struct ShardedReservationData {
|
||||
}
|
||||
|
||||
impl ReservationLedgerData {
|
||||
fn store(&self) -> &Arc<ECStore> {
|
||||
match self {
|
||||
Self::Single(data) => &data.store,
|
||||
Self::Sharded(data) => &data.store,
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket(&self) -> &str {
|
||||
match self {
|
||||
Self::Single(data) => &data.bucket,
|
||||
@@ -765,7 +758,7 @@ impl QuotaReservation {
|
||||
let Some(ledger) = self.ledger.as_ref() else {
|
||||
return;
|
||||
};
|
||||
crate::store::list_objects::observe_list_objects_mutation(ledger.store(), ledger.bucket()).await;
|
||||
crate::store::list_objects::observe_scanner_namespace_mutations(ledger.bucket(), 1);
|
||||
match settle(ledger, true).await {
|
||||
Ok(()) => self.ledger = None,
|
||||
Err(err) => log_deferred_settlement(ledger, "commit_deferred", &err),
|
||||
@@ -1290,7 +1283,7 @@ async fn reconcile_exact(store: &Arc<ECStore>, bucket: &str, ledger: &mut QuotaL
|
||||
}
|
||||
|
||||
async fn exact_bucket_usage(store: &Arc<ECStore>, bucket: &str) -> Result<u64> {
|
||||
crate::store::list_objects::observe_list_objects_mutation(store, bucket).await;
|
||||
crate::store::list_objects::observe_scanner_namespace_mutations(bucket, 1);
|
||||
Ok(compute_bucket_usage(Arc::clone(store), bucket).await?.size)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub use rustfs_replication::config_cache_serde;
|
||||
pub use rustfs_replication::{
|
||||
ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS,
|
||||
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
|
||||
use super::replication_config_boundary::ReplicationConfigurationExt as _;
|
||||
use super::replication_filemeta_boundary::{
|
||||
@@ -42,7 +42,7 @@ impl ReplicationLifecycleBridge {
|
||||
dead_code,
|
||||
reason = "declared boundary surface for the ECStore replication split plan; no caller in this port (backlog#1823)"
|
||||
)]
|
||||
pub(crate) fn new_config(config: PersistedReplicationConfiguration) -> ReplicationLifecycleConfig {
|
||||
pub(crate) fn new_config(config: ReplicationConfiguration) -> ReplicationLifecycleConfig {
|
||||
ReplicationConfig::new(Some(config), None)
|
||||
}
|
||||
|
||||
@@ -118,17 +118,16 @@ impl ReplicationLifecycleBridge {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::{PersistedReplicationDestination, PersistedReplicationRule};
|
||||
use s3s::dto::{Destination, ReplicationRule, ReplicationRuleStatus};
|
||||
|
||||
use super::super::replication_filemeta_boundary::{ReplicateTargetDecision, VersionPurgeStatusType};
|
||||
use super::*;
|
||||
|
||||
fn replication_rule() -> PersistedReplicationRule {
|
||||
PersistedReplicationRule {
|
||||
fn replication_rule() -> ReplicationRule {
|
||||
ReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: None,
|
||||
destination: PersistedReplicationDestination {
|
||||
destination: Destination {
|
||||
bucket: "arn:aws:s3:::target-bucket".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -138,13 +137,13 @@ mod tests {
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: Status::ENABLED.to_string(),
|
||||
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_pending_version_purge_preserves_replication_active_rule_behavior() {
|
||||
let config = ReplicationLifecycleBridge::new_config(PersistedReplicationConfiguration {
|
||||
let config = ReplicationLifecycleBridge::new_config(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule()],
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ use std::sync::Arc;
|
||||
use crate::bucket::{metadata::BucketMetadata, metadata_sys};
|
||||
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
|
||||
use rustfs_utils::path::path_join_buf;
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::replication_error_boundary::{Error, Result};
|
||||
@@ -36,11 +36,11 @@ impl ReplicationMetadataStore {
|
||||
pub(crate) const FORCE_DELETE_REPLICATION_FILE: &'static str = "config/replication/force-delete.bin";
|
||||
pub(crate) const FORCE_DELETE_REPLICATION_TRANSACTION_LOCK: &'static str = "config/replication/force-delete.bin.transaction";
|
||||
|
||||
pub(crate) async fn replication_config(bucket: &str) -> Result<(PersistedReplicationConfiguration, OffsetDateTime)> {
|
||||
pub(crate) async fn replication_config(bucket: &str) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
|
||||
metadata_sys::get_replication_config(bucket).await
|
||||
}
|
||||
|
||||
pub(crate) async fn optional_replication_config(bucket: &str) -> Result<Option<PersistedReplicationConfiguration>> {
|
||||
pub(crate) async fn optional_replication_config(bucket: &str) -> Result<Option<ReplicationConfiguration>> {
|
||||
let config = match Self::replication_config(bucket).await {
|
||||
Ok((config, _)) => Some(config),
|
||||
Err(err) => {
|
||||
|
||||
@@ -16,9 +16,7 @@ use std::{collections::HashMap, fmt, sync::Arc};
|
||||
|
||||
use super::replication_filemeta_boundary::metadata_keys;
|
||||
use crate::bucket::metadata::BucketMetadata;
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
|
||||
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
|
||||
use s3s::dto::{BucketVersioningStatus, ReplicationConfiguration, ReplicationRuleStatus, VersioningConfiguration};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::error;
|
||||
@@ -41,7 +39,7 @@ use super::replication_target_boundary::{BucketTargets, ReplicationTargetStore};
|
||||
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
||||
use super::runtime_boundary as runtime_sources;
|
||||
|
||||
pub(crate) async fn get_replication_config(bucket: &str) -> Result<Option<PersistedReplicationConfiguration>> {
|
||||
pub(crate) async fn get_replication_config(bucket: &str) -> Result<Option<ReplicationConfiguration>> {
|
||||
let config = ReplicationMetadataStore::optional_replication_config(bucket).await?;
|
||||
validate_delete_replication_config(&VersioningConfiguration::default(), config.as_ref())?;
|
||||
Ok(config)
|
||||
@@ -66,7 +64,7 @@ impl DeleteReplicationConfigSnapshot {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_configs_for_test(
|
||||
versioning: VersioningConfiguration,
|
||||
replication: Option<PersistedReplicationConfiguration>,
|
||||
replication: Option<ReplicationConfiguration>,
|
||||
) -> Self {
|
||||
let metadata = replication.map(|config| {
|
||||
let mut metadata = BucketMetadata::new("test-bucket");
|
||||
@@ -80,7 +78,7 @@ impl DeleteReplicationConfigSnapshot {
|
||||
&self.versioning
|
||||
}
|
||||
|
||||
pub fn replication_config(&self) -> Option<&PersistedReplicationConfiguration> {
|
||||
pub fn replication_config(&self) -> Option<&ReplicationConfiguration> {
|
||||
self.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.replication_config.as_ref())
|
||||
@@ -103,7 +101,7 @@ impl DeleteReplicationConfigSnapshot {
|
||||
pub(crate) fn active_delete_marker_rules_require_tags(&self, object: &str) -> bool {
|
||||
self.replication_config().is_some_and(|config| {
|
||||
config.rules.iter().any(|rule| {
|
||||
if rule.status == Status::DISABLED.as_str() {
|
||||
if rule.status == ReplicationRuleStatus::from_static(ReplicationRuleStatus::DISABLED) {
|
||||
return false;
|
||||
}
|
||||
if !object.starts_with(rule.prefix()) {
|
||||
@@ -124,12 +122,12 @@ impl DeleteReplicationConfigSnapshot {
|
||||
|
||||
fn validate_delete_replication_config(
|
||||
versioning: &VersioningConfiguration,
|
||||
config: Option<&PersistedReplicationConfiguration>,
|
||||
config: Option<&ReplicationConfiguration>,
|
||||
) -> Result<()> {
|
||||
if versioning
|
||||
.status
|
||||
.as_ref()
|
||||
.is_some_and(|status| !matches!(status.as_str(), "Enabled" | "Suspended"))
|
||||
.is_some_and(|status| !matches!(status.as_str(), BucketVersioningStatus::ENABLED | BucketVersioningStatus::SUSPENDED))
|
||||
{
|
||||
return Err(super::replication_error_boundary::Error::other(
|
||||
"bucket versioning configuration has an invalid status",
|
||||
@@ -146,7 +144,7 @@ fn validate_delete_replication_config(
|
||||
let role = config.role.trim();
|
||||
let mut role_destination = None;
|
||||
for rule in &config.rules {
|
||||
if rule.status.as_str() == Status::ENABLED.as_str() {
|
||||
if rule.status.as_str() == ReplicationRuleStatus::ENABLED {
|
||||
let destination = rule.destination.bucket.trim();
|
||||
if role.is_empty() && destination.is_empty() {
|
||||
return Err(super::replication_error_boundary::Error::other(
|
||||
@@ -171,7 +169,7 @@ fn validate_delete_replication_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replication_config_from_metadata(metadata: &BucketMetadata) -> Result<Option<&PersistedReplicationConfiguration>> {
|
||||
fn replication_config_from_metadata(metadata: &BucketMetadata) -> Result<Option<&ReplicationConfiguration>> {
|
||||
if !metadata.replication_config_xml.is_empty() && metadata.replication_config.is_none() {
|
||||
return Err(super::replication_error_boundary::Error::other(
|
||||
"persisted bucket replication configuration is invalid",
|
||||
@@ -252,15 +250,12 @@ pub(crate) async fn load_delete_replication_config_in(
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ReplicationConfig {
|
||||
/// Carried in the scanner's persisted data usage cache, so its serde form
|
||||
/// is the one the s3s DTO wrote there (see `config_cache_serde` in the replication crate).
|
||||
#[serde(with = "super::replication_config_boundary::config_cache_serde")]
|
||||
pub config: Option<PersistedReplicationConfiguration>,
|
||||
pub config: Option<ReplicationConfiguration>,
|
||||
pub remotes: Option<BucketTargets>,
|
||||
}
|
||||
|
||||
impl ReplicationConfig {
|
||||
pub fn new(config: Option<PersistedReplicationConfiguration>, remotes: Option<BucketTargets>) -> Self {
|
||||
pub fn new(config: Option<ReplicationConfiguration>, remotes: Option<BucketTargets>) -> Self {
|
||||
Self { config, remotes }
|
||||
}
|
||||
|
||||
@@ -473,7 +468,7 @@ fn check_replicate_delete_with_config(
|
||||
oi: &ObjectInfo,
|
||||
del_opts: &ObjectOptions,
|
||||
source_error: bool,
|
||||
config: Option<&PersistedReplicationConfiguration>,
|
||||
config: Option<&ReplicationConfiguration>,
|
||||
existing_delete_marker: bool,
|
||||
trust_persisted_replica_status: bool,
|
||||
) -> ReplicateDecision {
|
||||
@@ -592,7 +587,7 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
|
||||
}
|
||||
|
||||
fn metadata_target_should_replicate(
|
||||
cfg: &PersistedReplicationConfiguration,
|
||||
cfg: &ReplicationConfiguration,
|
||||
opts: &ObjectOpts,
|
||||
mopts: &MustReplicateOptions,
|
||||
arn: &str,
|
||||
@@ -602,21 +597,20 @@ fn metadata_target_should_replicate(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedOptionalReplicationStatus, PersistedReplicationDestination, PersistedReplicationFilter,
|
||||
PersistedReplicationRule, PersistedReplicationStatus, PersistedReplicationTag, PersistedSourceSelectionCriteria,
|
||||
use s3s::dto::{
|
||||
DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination,
|
||||
ReplicaModifications, ReplicationRule, ReplicationRuleFilter, ReplicationRuleStatus, SourceSelectionCriteria, Tag,
|
||||
};
|
||||
|
||||
use super::super::replication_filemeta_boundary::VersionPurgeStatusType;
|
||||
use super::super::replication_target_boundary::BucketTarget;
|
||||
use super::*;
|
||||
|
||||
fn replication_rule() -> PersistedReplicationRule {
|
||||
PersistedReplicationRule {
|
||||
fn replication_rule() -> ReplicationRule {
|
||||
ReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: None,
|
||||
destination: PersistedReplicationDestination {
|
||||
destination: Destination {
|
||||
bucket: "arn:aws:s3:::target-bucket".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -626,7 +620,7 @@ mod tests {
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: Status::ENABLED.to_string(),
|
||||
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,11 +629,11 @@ mod tests {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let mut rule = replication_rule();
|
||||
rule.destination.bucket = arn.to_string();
|
||||
rule.filter = Some(PersistedReplicationFilter {
|
||||
rule.filter = Some(ReplicationRuleFilter {
|
||||
prefix: Some("admitted/".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
let cfg = PersistedReplicationConfiguration {
|
||||
let cfg = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
};
|
||||
@@ -677,7 +671,7 @@ mod tests {
|
||||
assert!(!empty.replicate(&ObjectOpts::default()));
|
||||
|
||||
let config = ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule()],
|
||||
}),
|
||||
@@ -731,7 +725,7 @@ mod tests {
|
||||
fn delete_snapshot_rejects_enabled_rules_without_a_destination() {
|
||||
let mut rule = replication_rule();
|
||||
rule.destination.bucket.clear();
|
||||
let config = PersistedReplicationConfiguration {
|
||||
let config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
};
|
||||
@@ -747,7 +741,7 @@ mod tests {
|
||||
let first = replication_rule();
|
||||
let mut second = replication_rule();
|
||||
second.destination.bucket = "arn:aws:s3:::other-target".to_string();
|
||||
let config = PersistedReplicationConfiguration {
|
||||
let config = ReplicationConfiguration {
|
||||
role: "arn:aws:s3:::role-target".to_string(),
|
||||
rules: vec![first, second],
|
||||
};
|
||||
@@ -758,47 +752,47 @@ mod tests {
|
||||
#[test]
|
||||
fn delete_snapshot_rejects_unknown_status_values() {
|
||||
let invalid_versioning = VersioningConfiguration {
|
||||
status: Some("Enabld".to_string()),
|
||||
status: Some("Enabld".to_string().into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate_delete_replication_config(&invalid_versioning, None).is_err());
|
||||
|
||||
let mut invalid_rule = replication_rule();
|
||||
invalid_rule.status = "Enabld".to_string();
|
||||
let config = PersistedReplicationConfiguration {
|
||||
invalid_rule.status = "Enabld".to_string().into();
|
||||
let config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![invalid_rule],
|
||||
};
|
||||
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||
|
||||
let mut invalid_delete = replication_rule();
|
||||
invalid_delete.delete_replication = Some(PersistedReplicationStatus {
|
||||
status: "Enabld".to_string(),
|
||||
invalid_delete.delete_replication = Some(DeleteReplication {
|
||||
status: "Enabld".to_string().into(),
|
||||
});
|
||||
let config = PersistedReplicationConfiguration {
|
||||
let config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![invalid_delete],
|
||||
};
|
||||
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||
|
||||
let mut invalid_delete_marker = replication_rule();
|
||||
invalid_delete_marker.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
|
||||
status: Some("Enabld".to_string()),
|
||||
invalid_delete_marker.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||
status: Some("Enabld".to_string().into()),
|
||||
});
|
||||
let config = PersistedReplicationConfiguration {
|
||||
let config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![invalid_delete_marker],
|
||||
};
|
||||
assert!(validate_delete_replication_config(&VersioningConfiguration::default(), Some(&config)).is_err());
|
||||
|
||||
let mut invalid_replica_modifications = replication_rule();
|
||||
invalid_replica_modifications.source_selection_criteria = Some(PersistedSourceSelectionCriteria {
|
||||
replica_modifications: Some(PersistedReplicationStatus {
|
||||
status: "Enabld".to_string(),
|
||||
invalid_replica_modifications.source_selection_criteria = Some(SourceSelectionCriteria {
|
||||
replica_modifications: Some(ReplicaModifications {
|
||||
status: "Enabld".to_string().into(),
|
||||
}),
|
||||
sse_kms_encrypted_objects: None,
|
||||
});
|
||||
let config = PersistedReplicationConfiguration {
|
||||
let config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![invalid_replica_modifications],
|
||||
};
|
||||
@@ -810,11 +804,11 @@ mod tests {
|
||||
let mut metadata = BucketMetadata::new("bucket");
|
||||
metadata.versioning_config_xml = b"configured".to_vec();
|
||||
metadata.versioning_config = Some(VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
});
|
||||
metadata.replication_config_xml = b"configured".to_vec();
|
||||
metadata.replication_config = Some(PersistedReplicationConfiguration {
|
||||
metadata.replication_config = Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule()],
|
||||
});
|
||||
@@ -854,7 +848,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut inconsistent = BucketMetadata::new("bucket");
|
||||
inconsistent.replication_config = Some(PersistedReplicationConfiguration {
|
||||
inconsistent.replication_config = Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![replication_rule()],
|
||||
});
|
||||
@@ -869,11 +863,11 @@ mod tests {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let mut rule = replication_rule();
|
||||
rule.destination.bucket = arn.to_string();
|
||||
rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
|
||||
status: Some(Status::ENABLED.to_string()),
|
||||
rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
||||
});
|
||||
let mut metadata = BucketMetadata::new("bucket");
|
||||
metadata.replication_config = Some(PersistedReplicationConfiguration {
|
||||
metadata.replication_config = Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
});
|
||||
@@ -904,27 +898,27 @@ mod tests {
|
||||
fn delete_marker_source_read_is_required_only_for_tag_filtered_rules() {
|
||||
let mut prefix_rule = replication_rule();
|
||||
prefix_rule.prefix = Some("logs/".to_string());
|
||||
prefix_rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
|
||||
status: Some(Status::ENABLED.to_string()),
|
||||
prefix_rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
||||
});
|
||||
|
||||
let prefix_snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||
VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
},
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![prefix_rule],
|
||||
}),
|
||||
);
|
||||
|
||||
let mut tag_rule = replication_rule();
|
||||
tag_rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
|
||||
status: Some(Status::ENABLED.to_string()),
|
||||
tag_rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
||||
});
|
||||
tag_rule.filter = Some(PersistedReplicationFilter {
|
||||
tag: Some(PersistedReplicationTag {
|
||||
tag_rule.filter = Some(ReplicationRuleFilter {
|
||||
tag: Some(Tag {
|
||||
key: Some("class".to_string()),
|
||||
value: Some("audit".to_string()),
|
||||
}),
|
||||
@@ -932,10 +926,10 @@ mod tests {
|
||||
});
|
||||
let tag_snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||
VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
},
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![tag_rule],
|
||||
}),
|
||||
@@ -950,14 +944,14 @@ mod tests {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let mut rule = replication_rule();
|
||||
rule.destination.bucket = arn.to_string();
|
||||
rule.delete_replication = Some(PersistedReplicationStatus {
|
||||
status: Status::ENABLED.to_string(),
|
||||
rule.delete_replication = Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||
});
|
||||
rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
|
||||
status: Some(Status::DISABLED.to_string()),
|
||||
rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
|
||||
});
|
||||
let config = ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
}),
|
||||
@@ -991,22 +985,22 @@ mod tests {
|
||||
#[test]
|
||||
fn live_delete_does_not_trust_persisted_replica_status() {
|
||||
let mut rule = replication_rule();
|
||||
rule.delete_replication = Some(PersistedReplicationStatus {
|
||||
status: Status::ENABLED.to_string(),
|
||||
rule.delete_replication = Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||
});
|
||||
rule.source_selection_criteria = Some(PersistedSourceSelectionCriteria {
|
||||
replica_modifications: Some(PersistedReplicationStatus {
|
||||
status: Status::DISABLED.to_string(),
|
||||
rule.source_selection_criteria = Some(SourceSelectionCriteria {
|
||||
replica_modifications: Some(ReplicaModifications {
|
||||
status: s3s::dto::ReplicaModificationsStatus::from_static(s3s::dto::ReplicaModificationsStatus::DISABLED),
|
||||
}),
|
||||
sse_kms_encrypted_objects: None,
|
||||
});
|
||||
let replication = PersistedReplicationConfiguration {
|
||||
let replication = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
};
|
||||
let snapshot = DeleteReplicationConfigSnapshot::from_configs_for_test(
|
||||
VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
},
|
||||
Some(replication.clone()),
|
||||
@@ -1042,14 +1036,14 @@ mod tests {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let mut rule = replication_rule();
|
||||
rule.destination.bucket = arn.to_string();
|
||||
rule.delete_replication = Some(PersistedReplicationStatus {
|
||||
status: Status::ENABLED.to_string(),
|
||||
rule.delete_replication = Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||
});
|
||||
rule.delete_marker_replication = Some(PersistedOptionalReplicationStatus {
|
||||
status: Some(Status::DISABLED.to_string()),
|
||||
rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::DISABLED)),
|
||||
});
|
||||
let config = ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
}),
|
||||
|
||||
@@ -4794,10 +4794,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn heal_queue_marks_missing_versioning_state_as_missed() {
|
||||
use super::super::replication_target_boundary::BucketTargets;
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedReplicationConfiguration, PersistedReplicationDestination, PersistedReplicationRule,
|
||||
PersistedReplicationStatus,
|
||||
use s3s::dto::{
|
||||
DeleteReplication, DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule,
|
||||
ReplicationRuleStatus,
|
||||
};
|
||||
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
@@ -4812,14 +4811,14 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![PersistedReplicationRule {
|
||||
rules: vec![ReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: Some(PersistedReplicationStatus {
|
||||
status: Status::ENABLED.to_string(),
|
||||
delete_replication: Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||
}),
|
||||
destination: PersistedReplicationDestination {
|
||||
destination: Destination {
|
||||
bucket: arn.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -4829,7 +4828,7 @@ mod tests {
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: Status::ENABLED.to_string(),
|
||||
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||
}],
|
||||
}),
|
||||
Some(BucketTargets::default()),
|
||||
|
||||
@@ -85,8 +85,6 @@ use http_body_util::StreamBody;
|
||||
use metrics::counter;
|
||||
#[cfg(test)]
|
||||
use rmp_serde;
|
||||
#[cfg(test)]
|
||||
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
|
||||
use rustfs_s3_types::EventName;
|
||||
use rustfs_utils::http::{
|
||||
AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_MODE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_TAGGING_DIRECTIVE,
|
||||
@@ -94,6 +92,8 @@ use rustfs_utils::http::{
|
||||
insert_str, replication_target_versions,
|
||||
};
|
||||
use rustfs_utils::{DEFAULT_SIP_HASH_KEY, get_env_usize, sip_hash};
|
||||
#[cfg(test)]
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Display;
|
||||
use std::pin::Pin;
|
||||
@@ -6500,10 +6500,9 @@ mod tests {
|
||||
|
||||
use super::super::replication_target_boundary::{BucketTarget, BucketTargets};
|
||||
use super::*;
|
||||
use rustfs_gateway_types::dto::Status;
|
||||
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedReplicationDestination, PersistedReplicationRule, PersistedReplicationStatus,
|
||||
use s3s::dto::{
|
||||
BucketVersioningStatus, DeleteReplication, DeleteReplicationStatus, Destination, ExcludedPrefix, ReplicationRule,
|
||||
ReplicationRuleStatus, VersioningConfiguration,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use time::OffsetDateTime;
|
||||
@@ -8569,7 +8568,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let rcfg = ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: role.to_string(),
|
||||
rules: vec![],
|
||||
}),
|
||||
@@ -8595,7 +8594,7 @@ mod tests {
|
||||
ReplicationVersioningStore::install_prefix_state_test_config(
|
||||
&bucket,
|
||||
VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
@@ -8628,20 +8627,22 @@ mod tests {
|
||||
ReplicationVersioningStore::install_prefix_state_test_config(
|
||||
&bucket,
|
||||
VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
excluded_prefixes: Some(vec![Some("archive/".to_string())]),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
excluded_prefixes: Some(vec![ExcludedPrefix {
|
||||
prefix: Some("archive/".to_string()),
|
||||
}]),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let rcfg = ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![PersistedReplicationRule {
|
||||
rules: vec![ReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: Some(PersistedReplicationStatus {
|
||||
status: Status::ENABLED.to_string(),
|
||||
delete_replication: Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::ENABLED),
|
||||
}),
|
||||
destination: PersistedReplicationDestination {
|
||||
destination: Destination {
|
||||
bucket: arn.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -8651,7 +8652,7 @@ mod tests {
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: Status::ENABLED.to_string(),
|
||||
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||
}],
|
||||
}),
|
||||
Some(BucketTargets {
|
||||
@@ -8687,14 +8688,14 @@ mod tests {
|
||||
let admitted_arn = "arn:rustfs:replication:us-east-1:target:admitted";
|
||||
let current_role = "arn:rustfs:replication:us-east-1:target:current";
|
||||
let rcfg = ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: current_role.to_string(),
|
||||
rules: vec![PersistedReplicationRule {
|
||||
rules: vec![ReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: Some(PersistedReplicationStatus {
|
||||
status: Status::DISABLED.to_string(),
|
||||
delete_replication: Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED),
|
||||
}),
|
||||
destination: PersistedReplicationDestination {
|
||||
destination: Destination {
|
||||
bucket: current_role.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -8704,7 +8705,7 @@ mod tests {
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: Status::ENABLED.to_string(),
|
||||
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||
}],
|
||||
}),
|
||||
Some(BucketTargets {
|
||||
@@ -8752,12 +8753,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn heal_rejects_semantically_invalid_replication_config() {
|
||||
let rcfg = ReplicationConfig::new(
|
||||
Some(PersistedReplicationConfiguration {
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![PersistedReplicationRule {
|
||||
rules: vec![ReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: None,
|
||||
destination: PersistedReplicationDestination {
|
||||
destination: Destination {
|
||||
bucket: "arn:rustfs:replication:us-east-1:target:bucket".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -8767,7 +8768,7 @@ mod tests {
|
||||
prefix: Some(String::new()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: "Enabld".to_owned(),
|
||||
status: ReplicationRuleStatus::from_static("Enabld"),
|
||||
}],
|
||||
}),
|
||||
Some(BucketTargets::default()),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_gateway_types::persistence::PersistedReplicationConfiguration;
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
|
||||
use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt};
|
||||
use super::replication_filemeta_boundary::ReplicationType;
|
||||
@@ -20,7 +20,7 @@ use super::replication_filemeta_boundary::ReplicationType;
|
||||
pub(crate) struct ReplicationTargetConfigBridge;
|
||||
|
||||
impl ReplicationTargetConfigBridge {
|
||||
pub(crate) fn target_is_used_by_rules(config: &PersistedReplicationConfiguration, arn: &str) -> bool {
|
||||
pub(crate) fn target_is_used_by_rules(config: &ReplicationConfiguration, arn: &str) -> bool {
|
||||
config
|
||||
.filter_target_arns(&ObjectOpts {
|
||||
op_type: ReplicationType::All,
|
||||
@@ -37,7 +37,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn target_config_bridge_matches_role_target() {
|
||||
let config = PersistedReplicationConfiguration {
|
||||
let config = ReplicationConfiguration {
|
||||
role: " arn:target ".to_string(),
|
||||
rules: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use super::replication_error_boundary::Result;
|
||||
use crate::bucket::{versioning::VersioningApi as _, versioning_sys::BucketVersioningSys};
|
||||
#[cfg(test)]
|
||||
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
|
||||
use s3s::dto::VersioningConfiguration;
|
||||
#[cfg(test)]
|
||||
use std::{collections::HashMap, sync::LazyLock, sync::Mutex};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_gateway_types::cors_tagging::PersistedTag as Tag;
|
||||
use s3s::dto::Tag;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use url::form_urlencoded;
|
||||
|
||||
@@ -15,23 +15,9 @@
|
||||
use crate::disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
|
||||
use crate::error::{Error, Result, StorageError};
|
||||
use regex::Regex;
|
||||
use rustfs_gateway_types::cors_tagging::{
|
||||
CorsTaggingCodecError, PersistedCorsConfiguration, PersistedTagging, parse_cors, parse_tagging, serialize_cors,
|
||||
serialize_tagging,
|
||||
};
|
||||
use rustfs_gateway_types::persistence::{
|
||||
PersistedAccelerateConfiguration, PersistedBucketEncryptionConfiguration, PersistedBucketLoggingStatus,
|
||||
PersistedLifecycleConfiguration, PersistedNotificationConfiguration, PersistedObjectLockConfiguration,
|
||||
PersistedPublicAccessBlockConfiguration, PersistedReplicationConfiguration, PersistedRequestPaymentConfiguration,
|
||||
PersistedVersioningConfiguration, PersistedWebsiteConfiguration, PersistenceCodecError, parse_accelerate,
|
||||
parse_bucket_encryption, parse_bucket_logging, parse_lifecycle_legacy_dates, parse_notification, parse_object_lock,
|
||||
parse_public_access_block, parse_replication, parse_request_payment, parse_versioning, parse_website, serialize_accelerate,
|
||||
serialize_bucket_encryption, serialize_bucket_logging, serialize_lifecycle_legacy_dates, serialize_notification,
|
||||
serialize_object_lock, serialize_public_access_block, serialize_replication, serialize_request_payment, serialize_versioning,
|
||||
serialize_website,
|
||||
};
|
||||
use rustfs_utils::WindowsFileNameError;
|
||||
use rustfs_utils::path::SLASH_SEPARATOR;
|
||||
use s3s::xml;
|
||||
use tracing::instrument;
|
||||
|
||||
pub fn is_meta_bucketname(name: &str) -> bool {
|
||||
@@ -88,173 +74,37 @@ pub fn check_valid_bucket_name_strict(bucket_name: &str) -> Result<()> {
|
||||
check_bucket_name_common(bucket_name, true)
|
||||
}
|
||||
|
||||
/// A bucket configuration persisted as XML in the bucket metadata, together
|
||||
/// with the codec that reads and writes its stored bytes.
|
||||
///
|
||||
/// Every family reads and writes its stored bytes through the gateway
|
||||
/// persistence codecs (`rustfs_gateway_types::persistence` and
|
||||
/// `rustfs_gateway_types::cors_tagging`, rustfs/backlog#2745). The codecs keep
|
||||
/// the persisted bytes the `bucket_config_goldens` pin, so a document the old
|
||||
/// s3s codec wrote reads back and re-encodes unchanged.
|
||||
pub trait BucketConfigXml: Sized {
|
||||
/// Decodes the stored bytes of one configuration document.
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError>;
|
||||
|
||||
/// Encodes the value as RustFS stores it.
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError>;
|
||||
// RUSTFS_COMPAT_TODO(s3gate-metadata-xml): the s3s codec reads persisted XML during migration. Remove after every supported writer uses the gateway codec and every retained metadata object and backup archive is verified or rewritten.
|
||||
pub fn deserialize<T>(input: &[u8]) -> xml::DeResult<T>
|
||||
where
|
||||
T: for<'xml> xml::Deserialize<'xml>,
|
||||
{
|
||||
let mut d = xml::Deserializer::new(input);
|
||||
let ans = T::deserialize(&mut d)?;
|
||||
d.expect_eof()?;
|
||||
Ok(ans)
|
||||
}
|
||||
|
||||
/// Why a persisted bucket configuration could not be decoded or encoded.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BucketConfigXmlError {
|
||||
/// A gateway persistence codec refused the bytes or the value.
|
||||
#[error(transparent)]
|
||||
Persisted(#[from] PersistenceCodecError),
|
||||
/// The gateway CORS or Tagging codec refused the bytes or the value.
|
||||
#[error(transparent)]
|
||||
CorsTagging(#[from] CorsTaggingCodecError),
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "xml serialize helper with no caller in this port; the live sibling is deserialize (backlog#1823)"
|
||||
)]
|
||||
pub fn serialize_content<T: xml::SerializeContent>(val: &T) -> xml::SerResult<String> {
|
||||
let mut buf = Vec::with_capacity(256);
|
||||
{
|
||||
let mut ser = xml::Serializer::new(&mut buf);
|
||||
val.serialize_content(&mut ser)?;
|
||||
}
|
||||
Ok(String::from_utf8(buf).unwrap())
|
||||
}
|
||||
|
||||
/// Decodes one persisted bucket configuration document.
|
||||
pub fn deserialize<T: BucketConfigXml>(input: &[u8]) -> std::result::Result<T, BucketConfigXmlError> {
|
||||
T::decode_config_xml(input)
|
||||
}
|
||||
|
||||
/// Encodes one bucket configuration as RustFS persists it.
|
||||
pub fn serialize<T: BucketConfigXml>(val: &T) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
val.encode_config_xml()
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedReplicationConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_replication(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_replication(self)?)
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedLifecycleConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_lifecycle_legacy_dates(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_lifecycle_legacy_dates(self)?)
|
||||
}
|
||||
}
|
||||
|
||||
// The gateway writers of these families are infallible: a value that exists is
|
||||
// always encodable, so only the readers can refuse bytes.
|
||||
impl BucketConfigXml for PersistedAccelerateConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_accelerate(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_accelerate(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedBucketLoggingStatus {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_bucket_logging(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_bucket_logging(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedCorsConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_cors(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_cors(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedNotificationConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_notification(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_notification(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedObjectLockConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_object_lock(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_object_lock(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedPublicAccessBlockConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_public_access_block(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_public_access_block(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedRequestPaymentConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_request_payment(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_request_payment(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedBucketEncryptionConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_bucket_encryption(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_bucket_encryption(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedTagging {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_tagging(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_tagging(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedVersioningConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_versioning(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_versioning(self))
|
||||
}
|
||||
}
|
||||
|
||||
impl BucketConfigXml for PersistedWebsiteConfiguration {
|
||||
fn decode_config_xml(input: &[u8]) -> std::result::Result<Self, BucketConfigXmlError> {
|
||||
Ok(parse_website(input)?)
|
||||
}
|
||||
|
||||
fn encode_config_xml(&self) -> std::result::Result<Vec<u8>, BucketConfigXmlError> {
|
||||
Ok(serialize_website(self))
|
||||
pub fn serialize<T: xml::Serialize>(val: &T) -> xml::SerResult<Vec<u8>> {
|
||||
let mut buf = Vec::with_capacity(256);
|
||||
{
|
||||
let mut ser = xml::Serializer::new(&mut buf);
|
||||
val.serialize(&mut ser)?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub fn has_bad_path_component(path: &str) -> bool {
|
||||
@@ -584,40 +434,6 @@ mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// Every bucket-configuration family reads and writes through its own
|
||||
/// gateway codec (rustfs/backlog#2745): the family's golden document
|
||||
/// decodes and re-encodes to the same bytes, and a document of another
|
||||
/// family is refused instead of decoding into an empty configuration.
|
||||
#[test]
|
||||
fn every_bucket_config_family_uses_its_own_gateway_codec() {
|
||||
fn assert_family<T: BucketConfigXml>(family: &str, own: &[u8], foreign: &[u8]) {
|
||||
let value = deserialize::<T>(own).unwrap_or_else(|err| panic!("{family}: own document must decode: {err}"));
|
||||
let encoded = serialize(&value).unwrap_or_else(|err| panic!("{family}: value must encode: {err}"));
|
||||
assert_eq!(encoded, own, "{family}: re-encoding must keep the persisted bytes");
|
||||
assert!(deserialize::<T>(foreign).is_err(), "{family}: a foreign root element must be refused");
|
||||
}
|
||||
|
||||
macro_rules! golden {
|
||||
($family:literal) => {
|
||||
include_bytes!(concat!("../../tests/fixtures/bucket-config-goldens/", $family, "/minio.out.xml"))
|
||||
};
|
||||
}
|
||||
let tagging = golden!("tagging");
|
||||
let versioning = golden!("versioning");
|
||||
|
||||
assert_family::<PersistedAccelerateConfiguration>("accelerate", golden!("accelerate"), tagging);
|
||||
assert_family::<PersistedBucketLoggingStatus>("logging", golden!("logging"), tagging);
|
||||
assert_family::<PersistedCorsConfiguration>("cors", golden!("cors"), tagging);
|
||||
assert_family::<PersistedNotificationConfiguration>("notification", golden!("notification"), tagging);
|
||||
assert_family::<PersistedObjectLockConfiguration>("object_lock", golden!("object_lock"), tagging);
|
||||
assert_family::<PersistedPublicAccessBlockConfiguration>("public_access_block", golden!("public_access_block"), tagging);
|
||||
assert_family::<PersistedRequestPaymentConfiguration>("request_payment", golden!("request_payment"), tagging);
|
||||
assert_family::<PersistedBucketEncryptionConfiguration>("encryption", golden!("encryption"), tagging);
|
||||
assert_family::<PersistedTagging>("tagging", tagging, versioning);
|
||||
assert_family::<PersistedVersioningConfiguration>("versioning", versioning, tagging);
|
||||
assert_family::<PersistedWebsiteConfiguration>("website", golden!("website"), tagging);
|
||||
}
|
||||
|
||||
/// rustfs#5785: keys whose path segments exceed the on-disk NAME_MAX
|
||||
/// budget must be rejected up front as ObjectNameInvalid (4xx), not leak
|
||||
/// ENAMETOOLONG as InternalError 500 from the disk layer.
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
|
||||
use rustfs_utils::string::match_simple;
|
||||
use s3s::dto::{BucketVersioningStatus, VersioningConfiguration};
|
||||
|
||||
pub trait VersioningApi {
|
||||
fn enabled(&self) -> bool;
|
||||
@@ -28,10 +28,10 @@ pub trait VersioningApi {
|
||||
|
||||
impl VersioningApi for VersioningConfiguration {
|
||||
fn enabled(&self) -> bool {
|
||||
self.status.as_deref() == Some("Enabled")
|
||||
self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED))
|
||||
}
|
||||
fn prefix_enabled(&self, prefix: &str) -> bool {
|
||||
if self.status.as_deref() != Some("Enabled") {
|
||||
if self.status != Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -44,10 +44,12 @@ impl VersioningApi for VersioningConfiguration {
|
||||
}
|
||||
|
||||
if let Some(ref excluded_prefixes) = self.excluded_prefixes {
|
||||
for sprefix in excluded_prefixes.iter().flatten() {
|
||||
let pattern = format!("{sprefix}*");
|
||||
if match_simple(&pattern, prefix) {
|
||||
return false;
|
||||
for p in excluded_prefixes.iter() {
|
||||
if let Some(ref sprefix) = p.prefix {
|
||||
let pattern = format!("{sprefix}*");
|
||||
if match_simple(&pattern, prefix) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,11 +58,11 @@ impl VersioningApi for VersioningConfiguration {
|
||||
}
|
||||
|
||||
fn prefix_suspended(&self, prefix: &str) -> bool {
|
||||
if self.status.as_deref() == Some("Suspended") {
|
||||
if self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if self.status.as_deref() == Some("Enabled") {
|
||||
if self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)) {
|
||||
if prefix.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -73,10 +75,12 @@ impl VersioningApi for VersioningConfiguration {
|
||||
}
|
||||
|
||||
if let Some(ref excluded_prefixes) = self.excluded_prefixes {
|
||||
for sprefix in excluded_prefixes.iter().flatten() {
|
||||
let pattern = format!("{sprefix}*");
|
||||
if match_simple(&pattern, prefix) {
|
||||
return true;
|
||||
for p in excluded_prefixes.iter() {
|
||||
if let Some(ref sprefix) = p.prefix {
|
||||
let pattern = format!("{sprefix}*");
|
||||
if match_simple(&pattern, prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,12 +92,14 @@ impl VersioningApi for VersioningConfiguration {
|
||||
self.prefix_enabled(prefix) || self.prefix_suspended(prefix)
|
||||
}
|
||||
fn suspended(&self) -> bool {
|
||||
self.status.as_deref() == Some("Suspended")
|
||||
self.status == Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use s3s::dto::{BucketVersioningStatus, ExcludedPrefix};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct LegacyVersioning;
|
||||
@@ -128,8 +134,10 @@ mod tests {
|
||||
#[test]
|
||||
fn delete_state_treats_excluded_prefixes_as_unversioned() {
|
||||
let config = VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
excluded_prefixes: Some(vec![Some("archive/".to_string())]),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
excluded_prefixes: Some(vec![ExcludedPrefix {
|
||||
prefix: Some("archive/".to_string()),
|
||||
}]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -138,7 +146,7 @@ mod tests {
|
||||
assert_eq!(config.delete_state("live/object"), (true, false));
|
||||
|
||||
let suspended = VersioningConfiguration {
|
||||
status: Some("Suspended".to_owned()),
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::SUSPENDED)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(suspended.delete_state("archive/object"), (false, true));
|
||||
|
||||
@@ -17,7 +17,7 @@ use super::metadata::unreadable_config_refusal;
|
||||
use super::{metadata_sys::get_bucket_metadata_sys, versioning::VersioningApi};
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::Result;
|
||||
use rustfs_gateway_types::persistence::PersistedVersioningConfiguration as VersioningConfiguration;
|
||||
use s3s::dto::VersioningConfiguration;
|
||||
use tracing::{error, warn};
|
||||
|
||||
pub struct BucketVersioningSys {}
|
||||
@@ -163,7 +163,7 @@ mod tests {
|
||||
|
||||
fn enabled() -> VersioningConfiguration {
|
||||
VersioningConfiguration {
|
||||
status: Some("Enabled".to_owned()),
|
||||
status: Some(s3s::dto::BucketVersioningStatus::from_static(s3s::dto::BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::{
|
||||
future::Future,
|
||||
io::ErrorKind,
|
||||
pin::Pin,
|
||||
sync::{Arc, OnceLock, atomic::AtomicBool},
|
||||
sync::{Arc, OnceLock},
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -43,11 +43,6 @@ const EVENT_METACACHE_LISTING: &str = "metacache_listing";
|
||||
pub type AgreedFn = Box<dyn Fn(MetaCacheEntry) -> Pin<Box<dyn Future<Output = Result<(), DiskError>> + Send>> + Send + 'static>;
|
||||
pub type PartialFn =
|
||||
Box<dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||
pub(crate) type PartialResultFn = Box<
|
||||
dyn Fn(MetaCacheEntries, &[Option<DiskError>]) -> Pin<Box<dyn Future<Output = disk::error::Result<()>> + Send>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>;
|
||||
type FinishedFn = Box<dyn Fn(&[Option<DiskError>]) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -60,6 +55,7 @@ impl FallbackClaimTracker {
|
||||
self.claimed.lock().await.insert(disk.endpoint().to_string());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn claimed_keys(&self) -> HashSet<String> {
|
||||
self.claimed.lock().await.clone()
|
||||
}
|
||||
@@ -198,6 +194,8 @@ pub(crate) enum TestReaderBehavior {
|
||||
stopped: Arc<tokio::sync::Notify>,
|
||||
},
|
||||
IgnoreCancel,
|
||||
/// Entries followed by a terminator trailer saying the producer stopped at its limit.
|
||||
LimitReached(Vec<MetaCacheEntry>),
|
||||
ProducerError(DiskError),
|
||||
PrimaryErrorThenFallback(DiskError),
|
||||
PartialThenTimeout(Vec<MetaCacheEntry>),
|
||||
@@ -236,8 +234,6 @@ pub struct ListPathRawOptions {
|
||||
pub skip_walkdir_total_timeout: bool,
|
||||
pub walkdir_timeout: Option<Duration>,
|
||||
pub walkdir_stall_timeout: Option<Duration>,
|
||||
/// Shared terminal state for bounded producer walks.
|
||||
pub producer_limit_reached: Option<Arc<AtomicBool>>,
|
||||
pub agreed: Option<AgreedFn>,
|
||||
pub partial: Option<PartialFn>,
|
||||
pub finished: Option<FinishedFn>,
|
||||
@@ -248,7 +244,7 @@ pub struct ListPathRawOptions {
|
||||
#[cfg(test)]
|
||||
pub(crate) peek_timeout: Option<Duration>,
|
||||
#[cfg(test)]
|
||||
pub(crate) test_disk_io_context_observed: Option<Arc<AtomicBool>>,
|
||||
pub(crate) test_disk_io_context_observed: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
// pub agreed: Option<Arc<dyn Fn(MetaCacheEntry) + Send + Sync>>,
|
||||
// pub partial: Option<Arc<dyn Fn(MetaCacheEntries, &[Option<Error>]) + Send + Sync>>,
|
||||
// pub finished: Option<Arc<dyn Fn(&[Option<Error>]) + Send + Sync>>,
|
||||
@@ -273,7 +269,6 @@ impl Clone for ListPathRawOptions {
|
||||
skip_walkdir_total_timeout: self.skip_walkdir_total_timeout,
|
||||
walkdir_timeout: self.walkdir_timeout,
|
||||
walkdir_stall_timeout: self.walkdir_stall_timeout,
|
||||
producer_limit_reached: self.producer_limit_reached.clone(),
|
||||
#[cfg(test)]
|
||||
test_reader_behaviors: self.test_reader_behaviors.clone(),
|
||||
#[cfg(test)]
|
||||
@@ -301,44 +296,51 @@ fn walk_dir_options(opts: &ListPathRawOptions) -> WalkDirOptions {
|
||||
skip_total_timeout: opts.skip_walkdir_total_timeout,
|
||||
timeout_ms: opts.walkdir_timeout.map(duration_millis),
|
||||
stall_timeout_ms: opts.walkdir_stall_timeout.map(duration_millis),
|
||||
producer_limit_reached: opts.producer_limit_reached.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// How a merged raw listing stream ended.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ListPathRawOutcome {
|
||||
/// At least one producer stopped at its per-disk limit instead of at the
|
||||
/// end of its namespace, so the closed stream is not proof of exhaustion.
|
||||
/// Carried in-band by the metacache stream trailer, so remote producers
|
||||
/// report it as well.
|
||||
pub limit_reached: bool,
|
||||
}
|
||||
|
||||
pub async fn list_path_raw(rx: CancellationToken, opts: ListPathRawOptions) -> disk::error::Result<()> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, None, None).await
|
||||
list_path_raw_inner(rx, opts, None).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Like [`list_path_raw`], but reports how the merged stream ended.
|
||||
pub(crate) async fn list_path_raw_with_outcome(
|
||||
rx: CancellationToken,
|
||||
opts: ListPathRawOptions,
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_path_raw_with_claim_tracker(
|
||||
rx: CancellationToken,
|
||||
opts: ListPathRawOptions,
|
||||
claim_tracker: FallbackClaimTracker,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, Some(claim_tracker), None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_path_raw_with_partial_result(
|
||||
rx: CancellationToken,
|
||||
opts: ListPathRawOptions,
|
||||
claim_tracker: FallbackClaimTracker,
|
||||
partial_result: PartialResultFn,
|
||||
) -> disk::error::Result<()> {
|
||||
let rx = rx.child_token();
|
||||
let _cancel_guard = rx.clone().drop_guard();
|
||||
list_path_raw_inner(rx, opts, Some(claim_tracker), Some(partial_result)).await
|
||||
list_path_raw_inner(rx, opts, Some(claim_tracker)).await
|
||||
}
|
||||
|
||||
async fn list_path_raw_inner(
|
||||
rx: CancellationToken,
|
||||
opts: ListPathRawOptions,
|
||||
fallback_claim_tracker: Option<FallbackClaimTracker>,
|
||||
partial_result: Option<PartialResultFn>,
|
||||
) -> disk::error::Result<()> {
|
||||
) -> disk::error::Result<ListPathRawOutcome> {
|
||||
if opts.disks.is_empty() {
|
||||
return Err(DiskError::ErasureReadQuorum);
|
||||
}
|
||||
@@ -393,6 +395,14 @@ async fn list_path_raw_inner(
|
||||
out.close().await.expect("test entries should close");
|
||||
return Ok(());
|
||||
}
|
||||
TestReaderBehavior::LimitReached(entries) => {
|
||||
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
|
||||
out.write(&entries).await.expect("test entries should be written");
|
||||
out.close_with_limit_reached(true)
|
||||
.await
|
||||
.expect("test entries should close at the limit");
|
||||
return Ok(());
|
||||
}
|
||||
TestReaderBehavior::Stall => {
|
||||
let _held_writer = wr;
|
||||
cancel_rx_clone.cancelled().await;
|
||||
@@ -531,6 +541,16 @@ async fn list_path_raw_inner(
|
||||
last_err = None;
|
||||
continue;
|
||||
}
|
||||
TestReaderBehavior::LimitReached(entries) => {
|
||||
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
|
||||
out.write(&entries).await.expect("test fallback entries should be written");
|
||||
out.close_with_limit_reached(true)
|
||||
.await
|
||||
.expect("test fallback entries should close at the limit");
|
||||
need_fallback = false;
|
||||
last_err = None;
|
||||
continue;
|
||||
}
|
||||
TestReaderBehavior::ProducerError(err) | TestReaderBehavior::PrimaryErrorThenFallback(err) => {
|
||||
last_err = Some(err);
|
||||
continue;
|
||||
@@ -699,6 +719,9 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
let mut pending_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
|
||||
let mut peek_outcomes: Vec<Option<PeekOutcome>> = std::iter::repeat_with(|| None).take(readers.len()).collect();
|
||||
// Sticky across rounds: once any reader's trailer says its producer
|
||||
// stopped at the per-disk limit, the merged stream may hide more keys.
|
||||
let mut limit_reached = false;
|
||||
|
||||
loop {
|
||||
let mut current = MetaCacheEntry::default();
|
||||
@@ -710,7 +733,7 @@ async fn list_path_raw_inner(
|
||||
// );
|
||||
|
||||
if revjob_rx.is_cancelled() {
|
||||
return Ok(());
|
||||
return Ok(ListPathRawOutcome { limit_reached });
|
||||
}
|
||||
|
||||
let mut top_entries: Vec<Option<MetaCacheEntry>> = vec![None; readers.len()];
|
||||
@@ -760,6 +783,7 @@ async fn list_path_raw_inner(
|
||||
continue;
|
||||
}
|
||||
// eof
|
||||
limit_reached |= r.limit_reached() == Some(true);
|
||||
at_eof += 1;
|
||||
// warn!("list_path_raw: peek eof, disk: {}", i);
|
||||
continue;
|
||||
@@ -833,7 +857,7 @@ async fn list_path_raw_inner(
|
||||
*r = MetacacheReader::new(detached_rd);
|
||||
continue;
|
||||
}
|
||||
PeekOutcome::Cancelled => return Ok(()),
|
||||
PeekOutcome::Cancelled => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -892,7 +916,7 @@ async fn list_path_raw_inner(
|
||||
if let Some(finished_fn) = opts.finished.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -949,7 +973,7 @@ async fn list_path_raw_inner(
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = finished_fn(&errs) => {}
|
||||
}
|
||||
}
|
||||
@@ -969,7 +993,7 @@ async fn list_path_raw_inner(
|
||||
// warn!("list_path_raw: agreed_fn start, current: {:?}", ¤t.name);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
result = agreed_fn(current) => result?
|
||||
}
|
||||
// warn!("list_path_raw: agreed_fn done");
|
||||
@@ -990,61 +1014,58 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(partial_fn) = partial_result.as_ref() {
|
||||
if let Some(partial_fn) = opts.partial.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
result = partial_fn(MetaCacheEntries(top_entries), &errs) => result?,
|
||||
}
|
||||
} else if let Some(partial_fn) = opts.partial.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = revjob_rx.cancelled() => return Ok(()),
|
||||
_ = revjob_rx.cancelled() => return Ok(ListPathRawOutcome { limit_reached }),
|
||||
_ = partial_fn(MetaCacheEntries(top_entries), &errs) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(ListPathRawOutcome { limit_reached })
|
||||
});
|
||||
|
||||
let merge_started = std::time::Instant::now();
|
||||
let Some(revjob) = revjobs.join_next().await else {
|
||||
return Err(DiskError::Unexpected);
|
||||
};
|
||||
if let Err(err) = revjob.map_err(std::io::Error::other)? {
|
||||
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
|
||||
if is_missing_path_error(&err) {
|
||||
debug!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_missing_path",
|
||||
error = ?err,
|
||||
"Metacache merge job missing path skipped"
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_failed",
|
||||
error = ?err,
|
||||
"Metacache merge job failed"
|
||||
);
|
||||
}
|
||||
cancel_rx.cancel();
|
||||
jobs.abort_all();
|
||||
let outcome = match revjob.map_err(std::io::Error::other)? {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
rustfs_io_metrics::record_stage_duration("metacache_merge_failed", merge_started.elapsed().as_secs_f64() * 1000.0);
|
||||
if is_missing_path_error(&err) {
|
||||
debug!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_missing_path",
|
||||
error = ?err,
|
||||
"Metacache merge job missing path skipped"
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
event = EVENT_METACACHE_LISTING,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_METACACHE,
|
||||
bucket = %log_bucket,
|
||||
path = %log_path,
|
||||
state = "merge_job_failed",
|
||||
error = ?err,
|
||||
"Metacache merge job failed"
|
||||
);
|
||||
}
|
||||
cancel_rx.cancel();
|
||||
jobs.abort_all();
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if rx.is_cancelled() {
|
||||
cancel_rx.cancel();
|
||||
jobs.abort_all();
|
||||
return Ok(());
|
||||
return Ok(outcome);
|
||||
}
|
||||
rustfs_io_metrics::record_stage_duration("metacache_merge", merge_started.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
@@ -1113,7 +1134,7 @@ async fn list_path_raw_inner(
|
||||
}
|
||||
|
||||
// warn!("list_path_raw: done");
|
||||
Ok(())
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1132,6 +1153,7 @@ mod tests {
|
||||
use rustfs_filemeta::MetacacheWriter;
|
||||
use rustfs_filemeta::{FileInfo, FileMeta, MetadataResolutionParams};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1252,6 +1274,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_reports_limit_reached_from_any_reader_trailer() {
|
||||
let first = fallback_test_entry();
|
||||
let second = MetaCacheEntry {
|
||||
name: "bucket/object-2".to_string(),
|
||||
..fallback_test_entry()
|
||||
};
|
||||
// One bounded producer, one that ends naturally, one legacy empty
|
||||
// stream without any trailer.
|
||||
let outcome = list_path_raw_inner(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None, None, None],
|
||||
min_disks: 1,
|
||||
test_reader_behaviors: vec![
|
||||
TestReaderBehavior::LimitReached(vec![first.clone()]),
|
||||
TestReaderBehavior::Entries(vec![first, second]),
|
||||
TestReaderBehavior::Eof,
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("listing should complete");
|
||||
|
||||
assert!(
|
||||
outcome.limit_reached,
|
||||
"a reader whose trailer reports a limit stop must mark the merged stream as possibly incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_reports_natural_end_when_no_reader_trailer_says_limit() {
|
||||
let outcome = list_path_raw_inner(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None, None],
|
||||
min_disks: 1,
|
||||
test_reader_behaviors: vec![
|
||||
TestReaderBehavior::Entries(vec![fallback_test_entry()]),
|
||||
TestReaderBehavior::Eof,
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("listing should complete");
|
||||
|
||||
assert!(!outcome.limit_reached, "natural and legacy stream ends must not claim a limit stop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_returns_typed_quorum_error_for_multiple_drive_failures() {
|
||||
let err = list_path_raw(
|
||||
@@ -1738,39 +1813,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_propagates_partial_callback_failure() {
|
||||
let first = MetaCacheEntry {
|
||||
name: "bucket/object-a".to_string(),
|
||||
metadata: vec![1],
|
||||
..Default::default()
|
||||
};
|
||||
let second = MetaCacheEntry {
|
||||
name: "bucket/object-b".to_string(),
|
||||
metadata: vec![2],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = list_path_raw_with_partial_result(
|
||||
CancellationToken::new(),
|
||||
ListPathRawOptions {
|
||||
disks: vec![None, None],
|
||||
min_disks: 2,
|
||||
test_reader_behaviors: vec![
|
||||
TestReaderBehavior::Entries(vec![first]),
|
||||
TestReaderBehavior::Entries(vec![second]),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
FallbackClaimTracker::default(),
|
||||
Box::new(move |_, _| Box::pin(async { Err(DiskError::ErasureReadQuorum) })),
|
||||
)
|
||||
.await
|
||||
.expect_err("a resolution failure after reader advancement must fail the listing");
|
||||
|
||||
assert_eq!(err, DiskError::ErasureReadQuorum);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_path_raw_cancels_a_blocked_partial_callback() {
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
@@ -105,7 +105,7 @@ pub async fn tier_mutation_control_time_out_client(
|
||||
|
||||
/// Build a `NodeServiceClient` bound to the [`ChannelClass`]-appropriate channel for `addr`.
|
||||
///
|
||||
/// Bulk `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion) pass
|
||||
/// Bulk `bytes`-carrying RPCs, including native pool metadata recovery inventories, pass
|
||||
/// [`ChannelClass::Bulk`] so, when channel isolation is enabled, they are physically isolated
|
||||
/// 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.
|
||||
@@ -357,6 +357,7 @@ fn rename_data_grpc_stage(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/node_service.NodeService/RenameData"
|
||||
| "/node_service.NodeService/RenameMultipartData"
|
||||
| "/node_service.NodeService/RenameDataAtIncarnation"
|
||||
| "/node_service.NodeService/RenameDataWithQuotaFence"
|
||||
)
|
||||
@@ -379,6 +380,7 @@ where
|
||||
}
|
||||
|
||||
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
|
||||
let rpc_method = request.extensions().get::<tonic::GrpcMethod<'_>>().cloned();
|
||||
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()
|
||||
@@ -447,13 +449,21 @@ where
|
||||
let response_state = verify_tonic_peer_replay_capabilities_response(&audience, challenge, response.headers());
|
||||
if let Err(error) = &response_state
|
||||
&& (response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER)
|
||||
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER))
|
||||
|| response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER)
|
||||
|| matches!(sent_state.cache_capability, Some(PeerReplayCapability::Capable { .. })))
|
||||
{
|
||||
debug!(
|
||||
event = "internode_rpc_capability_proof_rejected",
|
||||
component = "ecstore",
|
||||
subsystem = "rpc_client",
|
||||
result = "rejected",
|
||||
audience = audience.rsplit('@').next().unwrap_or("unknown"),
|
||||
rpc_service = rpc_method.as_ref().map(|method| method.service()).unwrap_or("unknown"),
|
||||
rpc_method = rpc_method.as_ref().map(|method| method.method()).unwrap_or("unknown"),
|
||||
http_status = response.status().as_u16(),
|
||||
grpc_status = response.headers().get("grpc-status").and_then(|value| value.to_str().ok()).unwrap_or("trailers"),
|
||||
capability_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_HEADER),
|
||||
capability_proof_present = response.headers().contains_key(RPC_REPLAY_CACHE_CAPABILITY_PROOF_HEADER),
|
||||
error = %error,
|
||||
"internode RPC capability proof rejected"
|
||||
)
|
||||
|
||||
@@ -631,6 +631,17 @@ fn decode_cross_pool_fence_capability(expected_member: &str, result: &[u8]) -> R
|
||||
Ok((version, epoch))
|
||||
}
|
||||
|
||||
fn decode_mrf_lifecycle_reader_capability(expected_member: &str, result: &[u8]) -> Result<(u8, Uuid)> {
|
||||
let (&maximum_format_version, identity) = result
|
||||
.split_first()
|
||||
.ok_or_else(|| Error::other("peer returned a truncated MRF lifecycle reader capability"))?;
|
||||
if maximum_format_version == 0 || expected_member.trim().is_empty() {
|
||||
return Err(Error::other("peer returned an invalid MRF lifecycle reader capability"));
|
||||
}
|
||||
let epoch = decode_remote_version_state_capability(expected_member, identity)?;
|
||||
Ok((maximum_format_version, epoch))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PeerLiveEventsBatch {
|
||||
pub events: Vec<u8>,
|
||||
@@ -1954,15 +1965,6 @@ impl PeerRestClient {
|
||||
Ok((self.topology_member.clone(), epoch))
|
||||
}
|
||||
|
||||
pub async fn probe_restore_copyback_capability(&self, topology_fingerprint: String) -> Result<(String, Uuid)> {
|
||||
let probe = rustfs_protos::restore_copyback_capability_probe(Uuid::new_v4().as_bytes());
|
||||
let result = self
|
||||
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
|
||||
.await?;
|
||||
let epoch = decode_remote_version_state_capability(&self.topology_member, &result)?;
|
||||
Ok((self.topology_member.clone(), epoch))
|
||||
}
|
||||
|
||||
pub async fn probe_cross_pool_fence(&self, topology_fingerprint: String) -> Result<(String, u32, Uuid)> {
|
||||
let mut probe = rustfs_protos::CROSS_POOL_FENCE_CAPABILITY_PROBE_PREFIX.to_vec();
|
||||
probe.extend_from_slice(Uuid::new_v4().as_bytes());
|
||||
@@ -1973,6 +1975,16 @@ impl PeerRestClient {
|
||||
Ok((self.topology_member.clone(), supported_version, epoch))
|
||||
}
|
||||
|
||||
/// Reports one peer's reader support without authorizing a lifecycle writer or rollback.
|
||||
pub async fn probe_mrf_lifecycle_reader(&self, topology_fingerprint: String) -> Result<(String, u8, Uuid)> {
|
||||
let probe = rustfs_protos::mrf_lifecycle_reader_capability_probe(Uuid::new_v4().as_bytes());
|
||||
let result = self
|
||||
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
|
||||
.await?;
|
||||
let (maximum_format_version, epoch) = decode_mrf_lifecycle_reader_capability(&self.topology_member, &result)?;
|
||||
Ok((self.topology_member.clone(), maximum_format_version, epoch))
|
||||
}
|
||||
|
||||
pub async fn probe_ilm_recovery_export(&self, topology_fingerprint: String) -> Result<(String, Uuid)> {
|
||||
let probe = rustfs_protos::ilm_recovery_export_capability_probe(Uuid::new_v4().as_bytes());
|
||||
let result = self
|
||||
@@ -4342,6 +4354,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_lifecycle_reader_capability_decoder_preserves_the_advertised_version() {
|
||||
let epoch = Uuid::new_v4();
|
||||
for version in [1, 2, u8::MAX] {
|
||||
let result = rustfs_protos::encode_mrf_lifecycle_reader_capability(version, "node-a:9000", epoch.as_bytes())
|
||||
.expect("capability frame");
|
||||
assert_eq!(
|
||||
decode_mrf_lifecycle_reader_capability("node-a:9000", &result).expect("reported capability"),
|
||||
(version, epoch)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrf_lifecycle_reader_capability_decoder_rejects_unbound_or_malformed_identity() {
|
||||
let epoch = Uuid::new_v4();
|
||||
let valid =
|
||||
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", epoch.as_bytes()).expect("capability frame");
|
||||
let mut invalid = vec![Vec::new(), vec![2], valid[..valid.len() - 1].to_vec()];
|
||||
let mut zero = valid.clone();
|
||||
zero[0] = 0;
|
||||
invalid.push(zero);
|
||||
let mut trailing = valid.clone();
|
||||
trailing.push(0);
|
||||
invalid.push(trailing);
|
||||
let mut length_overflow = valid.clone();
|
||||
length_overflow[1..9].copy_from_slice(&u64::MAX.to_be_bytes());
|
||||
invalid.push(length_overflow);
|
||||
let mut invalid_utf8 = valid.clone();
|
||||
invalid_utf8[9] = 0xff;
|
||||
invalid.push(invalid_utf8);
|
||||
invalid.push(
|
||||
rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "node-a:9000", Uuid::nil().as_bytes())
|
||||
.expect("nil epoch fixture"),
|
||||
);
|
||||
for result in invalid {
|
||||
assert!(decode_mrf_lifecycle_reader_capability("node-a:9000", &result).is_err());
|
||||
}
|
||||
assert!(decode_mrf_lifecycle_reader_capability("node-b:9000", &valid).is_err());
|
||||
let empty = rustfs_protos::encode_mrf_lifecycle_reader_capability(2, "", epoch.as_bytes()).expect("empty member fixture");
|
||||
assert!(decode_mrf_lifecycle_reader_capability("", &empty).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_version_state_capability_decoder_fails_closed() {
|
||||
let epoch = Uuid::new_v4();
|
||||
|
||||
@@ -22,13 +22,17 @@ use crate::cluster::rpc::internode_data_transport::{
|
||||
WriteStreamRequest,
|
||||
};
|
||||
use crate::disk::error::{Error, Result};
|
||||
use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartSourceOperation, MultipartSourceRequest, MultipartSourceResponse,
|
||||
};
|
||||
use crate::disk::{
|
||||
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection,
|
||||
DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation,
|
||||
DiskOption, FileInfoVersions, FileReader, FileWriter, GuardedDeleteVersionsPayload, GuardedFileInfoVersions,
|
||||
PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken,
|
||||
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
|
||||
PartTransactionAction, PoolMetaNativeRecoveryRequest, PoolMetaNativeRecoveryResponse, 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,
|
||||
@@ -62,11 +66,14 @@ use rustfs_protos::proto_gen::node_service::{
|
||||
BatchReadVersionRequest, BatchReadVersionResponse, CheckPartsRequest, CompareAndUpdateFileOutcome,
|
||||
CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
|
||||
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, GuardedDeleteVersionsRequest, ListDirRequest,
|
||||
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest,
|
||||
ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest,
|
||||
RecoverDeleteTransactionRequest, RenameDataRequest, RenameFileRequest, SettlePartTransactionRequest,
|
||||
SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest,
|
||||
UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest, node_service_client::NodeServiceClient,
|
||||
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PoolMetaNativeRecoveryRequest as PoolMetaNativeRecoveryRpcRequest,
|
||||
PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse,
|
||||
ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RecoverDeleteTransactionRequest,
|
||||
RecoverMultipartSourceRequest as RecoverMultipartSourceRpcRequest,
|
||||
RecoverMultipartSourceResponse as RecoverMultipartSourceRpcResponse, RenameDataRequest, RenameFileRequest,
|
||||
SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest,
|
||||
SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest,
|
||||
node_service_client::NodeServiceClient,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
@@ -237,6 +244,36 @@ fn attach_quota_mutation_body_digest<T>(
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(request, &body).map_err(Error::other)
|
||||
}
|
||||
|
||||
fn decode_multipart_source_recovery_response(
|
||||
request: &MultipartSourceRequest,
|
||||
response: RecoverMultipartSourceRpcResponse,
|
||||
) -> Result<MultipartSourceResponse> {
|
||||
if response.protocol_version != u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION) {
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
if response.result.len() > MultipartSourceOperation::max_wire_payload_bytes() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if !response.success {
|
||||
if !response.result.is_empty() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let error = response.error.ok_or(DiskError::FileCorrupt)?;
|
||||
if DiskError::from_u32(error.code).is_none() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
if response.error.is_some() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let result: MultipartSourceResponse =
|
||||
rustfs_protos::decode_multipart_source_recovery_payload(&response.result).map_err(|_| DiskError::FileCorrupt)?;
|
||||
result.validate_wire()?;
|
||||
result.validate_for(request)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn decode_volume_infos(volume_infos: Vec<String>) -> Result<Vec<VolumeInfo>> {
|
||||
volume_infos
|
||||
.into_iter()
|
||||
@@ -2128,7 +2165,7 @@ impl RemoteDisk {
|
||||
.map_err(|err| Error::RemoteClientUnavailable(err.to_string()))
|
||||
}
|
||||
|
||||
/// Client for large `bytes`-carrying RPCs (ReadAll/WriteAll/CompareAndUpdateFile/ReadMultiple/BatchReadVersion).
|
||||
/// Client for large `bytes`-carrying RPCs, including native pool metadata recovery inventories.
|
||||
/// Routes onto the isolated bulk channel pool so large transfers cannot head-of-line block
|
||||
/// lock/health RPCs (grpc-optimization P1). Falls back to the control channel when isolation
|
||||
/// is disabled.
|
||||
@@ -2224,6 +2261,38 @@ fn decode_msgpack_or_json<T: DeserializeOwned>(binary: &[u8], json: &str, value_
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_multipart_publication_response(binary: &[u8]) -> Result<RenameDataResp> {
|
||||
let result = if binary.len() > MultipartSourceOperation::max_wire_payload_bytes() {
|
||||
Err(DiskError::FileCorrupt)
|
||||
} else {
|
||||
rustfs_protos::decode_multipart_source_recovery_payload(binary).map_err(|_| DiskError::FileCorrupt)
|
||||
};
|
||||
if result.is_ok() {
|
||||
crate::cluster::rpc::runtime_sources::record_response_msgpack_decode("RenameDataResp");
|
||||
} else {
|
||||
crate::cluster::rpc::runtime_sources::record_response_msgpack_decode_error("RenameDataResp");
|
||||
}
|
||||
// A codec error must never echo a malformed opaque capability.
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_multipart_publication_envelope(response: &rustfs_protos::proto_gen::node_service::RenameDataResponse) -> Result<()> {
|
||||
if !response.success {
|
||||
if !response.rename_data_resp.is_empty() || !response.rename_data_resp_bin.is_empty() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let error = response.error.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
if DiskError::from_u32(error.code).is_none() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
return Err(error.clone().into());
|
||||
}
|
||||
if response.error.is_some() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_version_stage_timer(attribution_enabled: bool) -> Option<Instant> {
|
||||
attribution_enabled.then(Instant::now)
|
||||
}
|
||||
@@ -2399,6 +2468,18 @@ impl RemoteDisk {
|
||||
dst_path: &str,
|
||||
scanner_publication_lease_token: Option<Uuid>,
|
||||
) -> Result<RenameDataResp> {
|
||||
let multipart_rename = src_volume == crate::disk::RUSTFS_META_MULTIPART_BUCKET;
|
||||
let pool_meta_cas = rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&fi.metadata,
|
||||
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
|
||||
);
|
||||
if pool_meta_cas
|
||||
&& (dst_volume != crate::disk::RUSTFS_META_BUCKET
|
||||
|| dst_path != "pool.bin"
|
||||
|| scanner_publication_lease_token.is_some())
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
trace!(
|
||||
event = EVENT_REMOTE_DISK_RPC,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -2441,9 +2522,12 @@ impl RemoteDisk {
|
||||
&fi.metadata,
|
||||
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
|
||||
);
|
||||
if quota_fenced {
|
||||
if pool_meta_cas {
|
||||
let body = canonical_body.map_err(|_| Error::other("pool metadata CAS body length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
} else if quota_fenced {
|
||||
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
|
||||
} else if scanner_publication_lease_token.is_some() || incarnation_bound {
|
||||
} else if multipart_rename || scanner_publication_lease_token.is_some() || incarnation_bound {
|
||||
let canonical_body =
|
||||
canonical_body.map_err(|_| Error::other("rename_data request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
|
||||
@@ -2458,7 +2542,12 @@ impl RemoteDisk {
|
||||
let rpc_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response = rustfs_io_metrics::observe_put_stage_future(
|
||||
async {
|
||||
if quota_fenced {
|
||||
if multipart_rename {
|
||||
// Old peers must reject before an upload source can be changed.
|
||||
client.rename_multipart_data(request).await
|
||||
} else if pool_meta_cas {
|
||||
client.rename_data_with_pool_meta_cas(request).await
|
||||
} else if quota_fenced {
|
||||
// The method itself proves quota-token enforcement; an old peer must fail closed.
|
||||
client.rename_data_with_quota_fence(request).await
|
||||
} else if incarnation_bound {
|
||||
@@ -2472,7 +2561,14 @@ impl RemoteDisk {
|
||||
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?;
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if (multipart_rename || pool_meta_cas) && status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?;
|
||||
let response_process_started = rustfs_io_metrics::put_stage_timer();
|
||||
let response_headers_started = rustfs_io_metrics::put_stage_timer();
|
||||
let _ = response.metadata().len();
|
||||
@@ -2492,7 +2588,9 @@ impl RemoteDisk {
|
||||
);
|
||||
|
||||
let success_check_started = rustfs_io_metrics::put_stage_timer();
|
||||
if !response.success {
|
||||
if multipart_rename {
|
||||
validate_multipart_publication_envelope(&response)?;
|
||||
} else if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
@@ -2501,11 +2599,15 @@ impl RemoteDisk {
|
||||
);
|
||||
|
||||
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",
|
||||
)?;
|
||||
let rename_data_resp = if multipart_rename {
|
||||
decode_multipart_publication_response(&response.rename_data_resp_bin)?
|
||||
} else {
|
||||
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,
|
||||
@@ -2515,6 +2617,14 @@ impl RemoteDisk {
|
||||
response_process_started,
|
||||
);
|
||||
|
||||
if multipart_rename {
|
||||
let publication = rename_data_resp.multipart_source.as_ref().ok_or(DiskError::FileCorrupt)?;
|
||||
publication.validate_wire()?;
|
||||
let expected = crate::disk::multipart_source::publication_identity(fi)?.ok_or(DiskError::FileCorrupt)?;
|
||||
if publication.publication != expected {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
}
|
||||
Ok(rename_data_resp)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
@@ -2907,6 +3017,20 @@ impl DiskAPI for RemoteDisk {
|
||||
let file_info_bin = encode_file_info_msgpack(&fi)?;
|
||||
let opts_bin = encode_delete_options_msgpack(&opts)?;
|
||||
let conditional_marker = opts.expected_delete_marker.is_some();
|
||||
let pool_meta_undo = rustfs_utils::http::metadata_compat::contains_key_str(
|
||||
&fi.metadata,
|
||||
crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX,
|
||||
);
|
||||
if pool_meta_undo
|
||||
&& (volume != crate::disk::RUSTFS_META_BUCKET
|
||||
|| path != "pool.bin"
|
||||
|| !opts.undo_write
|
||||
|| opts.undo_delete
|
||||
|| force_del_marker
|
||||
|| conditional_marker)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
let file_info = serde_json::to_string(&fi)?;
|
||||
let opts = serde_json::to_string(&opts)?;
|
||||
|
||||
@@ -2926,7 +3050,7 @@ impl DiskAPI for RemoteDisk {
|
||||
});
|
||||
let canonical_body = rustfs_protos::canonical_delete_version_request_body(request.get_ref());
|
||||
let incarnation_bound = !request.get_ref().bucket_incarnation_id.is_empty();
|
||||
if incarnation_bound {
|
||||
if incarnation_bound || pool_meta_undo {
|
||||
let body = canonical_body.map_err(|_| Error::other("delete-version body length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
} else {
|
||||
@@ -2935,7 +3059,15 @@ impl DiskAPI for RemoteDisk {
|
||||
|
||||
// The marker-specific method rejects older peers; its body digest
|
||||
// also binds any incarnation, so neither precondition can be lost.
|
||||
let response = if conditional_marker {
|
||||
let response = if pool_meta_undo {
|
||||
client.undo_pool_meta_write(request).await.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
} else if conditional_marker {
|
||||
client.delete_retired_marker(request).await?
|
||||
} else if incarnation_bound {
|
||||
client.delete_version_at_incarnation(request).await?
|
||||
@@ -3617,6 +3749,107 @@ impl DiskAPI for RemoteDisk {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_data_with_pool_meta_cas(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
if !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn undo_pool_meta_write(&self, volume: &str, path: &str, fi: FileInfo, opts: DeleteOptions) -> Result<()> {
|
||||
if !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, crate::disk::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.delete_version(volume, path, fi, false, opts).await
|
||||
}
|
||||
|
||||
async fn pool_meta_native_recovery(
|
||||
&self,
|
||||
operation: PoolMetaNativeRecoveryRequest,
|
||||
) -> Result<PoolMetaNativeRecoveryResponse> {
|
||||
self.execute_with_timeout_for_op(
|
||||
"pool_meta_native_recovery",
|
||||
|| async {
|
||||
let payload = rmp_serde::to_vec_named(&operation).map_err(Error::other)?;
|
||||
if payload.len() > rustfs_protos::POOL_META_NATIVE_RECOVERY_MAX_PAYLOAD_BYTES {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
let mut client = self.get_bulk_client().await?;
|
||||
let mut request = Request::new(PoolMetaNativeRecoveryRpcRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
payload: payload.into(),
|
||||
});
|
||||
let body = rustfs_protos::canonical_pool_meta_native_recovery_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("pool metadata recovery body length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
let response = client
|
||||
.pool_meta_native_recovery(request)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
if !response.success {
|
||||
return Err(response.error.unwrap_or_default().into());
|
||||
}
|
||||
rustfs_protos::decode_pool_meta_native_recovery_payload(&response.payload).map_err(Error::other)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(&self, mut source: MultipartSourceRequest) -> Result<MultipartSourceResponse> {
|
||||
source.prepare_wire()?;
|
||||
let payload = rmp_serde::to_vec_named(&source.operation).map_err(Error::other)?;
|
||||
if payload.len() > MultipartSourceOperation::max_wire_payload_bytes() {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
self.execute_with_timeout_for_op(
|
||||
"recover_multipart_source",
|
||||
|| async {
|
||||
let mut client = self.get_bulk_client().await?;
|
||||
let mut request = Request::new(RecoverMultipartSourceRpcRequest {
|
||||
disk: self.endpoint.to_string(),
|
||||
path: source.path.clone(),
|
||||
protocol_version: u32::from(source.protocol_version),
|
||||
operation: payload.into(),
|
||||
});
|
||||
let body = rustfs_protos::canonical_recover_multipart_source_request_body(request.get_ref())
|
||||
.map_err(|_| Error::other("multipart source recovery request length cannot be represented"))?;
|
||||
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
|
||||
let response = client
|
||||
.recover_multipart_source(request)
|
||||
.await
|
||||
.map_err(|status| {
|
||||
if status.code() == Code::Unimplemented {
|
||||
DiskError::UnsupportedDisk
|
||||
} else {
|
||||
status.into()
|
||||
}
|
||||
})?
|
||||
.into_inner();
|
||||
decode_multipart_source_recovery_response(&source, response)
|
||||
},
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn list_dir(&self, _origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
|
||||
trace!(volume, dir_path, "Remote disk list_dir RPC started");
|
||||
@@ -4553,6 +4786,322 @@ mod tests {
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
fn multipart_source_claim_request() -> MultipartSourceRequest {
|
||||
use crate::disk::multipart_source::{
|
||||
MultipartRecoveryRound, MultipartSourcePhase, MultipartSourceSnapshot, SourceGeneration, SourceGeometry,
|
||||
SourceLineage,
|
||||
};
|
||||
let original_data_dir = Uuid::new_v4();
|
||||
let lineage = SourceLineage {
|
||||
bucket: "bucket".to_string(),
|
||||
object: "object".to_string(),
|
||||
path: "upload".to_string(),
|
||||
original_data_dir,
|
||||
original_version_id: None,
|
||||
created_unix_nanos: 1,
|
||||
original_identity: [1; 32],
|
||||
geometry: SourceGeometry {
|
||||
data_blocks: 2,
|
||||
parity_blocks: 2,
|
||||
block_size: 1024,
|
||||
distribution: vec![1, 2, 3, 4],
|
||||
},
|
||||
};
|
||||
let generation = SourceGeneration {
|
||||
epoch: 0,
|
||||
nonce: original_data_dir,
|
||||
};
|
||||
MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: lineage.path.clone(),
|
||||
operation: MultipartSourceOperation::Claim {
|
||||
expected: MultipartSourceSnapshot {
|
||||
disk_id: Uuid::new_v4(),
|
||||
physical_slot: 0,
|
||||
lineage: lineage.clone(),
|
||||
generation,
|
||||
revision: 0,
|
||||
phase: MultipartSourcePhase::Active,
|
||||
state_sha256: [2; 32],
|
||||
head_sha256: Some([3; 32]),
|
||||
inventory_sha256: [4; 32],
|
||||
capability_sha256: None,
|
||||
publication: None,
|
||||
},
|
||||
round: MultipartRecoveryRound {
|
||||
lineage,
|
||||
from_generation: generation,
|
||||
old_attempt: Uuid::new_v4(),
|
||||
generation: SourceGeneration {
|
||||
epoch: 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
},
|
||||
round: Uuid::new_v4(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_source_recovery_old_peer_rejects_without_generic_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
for request in [
|
||||
MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: "upload".to_string(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
},
|
||||
multipart_source_claim_request(),
|
||||
] {
|
||||
assert_eq!(
|
||||
remote
|
||||
.recover_multipart_source(request)
|
||||
.await
|
||||
.expect_err("old peer must reject before recovery work"),
|
||||
DiskError::UnsupportedDisk
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec!["/node_service.NodeService/RecoverMultipartSource"; 2],
|
||||
"read and mutation requests must never fall back to generic reads, writes or legacy recovery",
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_publication_response_is_strict_and_redacts_codec_errors() {
|
||||
let encoded = rmp_serde::to_vec_named(&RenameDataResp::default()).expect("publication response");
|
||||
assert!(decode_multipart_publication_response(&encoded).is_ok());
|
||||
let mut trailing = encoded;
|
||||
trailing.push(0xc0);
|
||||
let secret = "private-undo-capability";
|
||||
let malformed =
|
||||
rmp_serde::to_vec_named(&serde_json::json!({ "multipart_source": secret })).expect("malformed capability response");
|
||||
for binary in [Vec::new(), trailing, malformed] {
|
||||
let error = decode_multipart_publication_response(&binary).expect_err("invalid publication response");
|
||||
assert!(matches!(error, DiskError::FileCorrupt));
|
||||
assert!(!error.to_string().contains(secret));
|
||||
}
|
||||
use rustfs_protos::proto_gen::node_service::RenameDataResponse;
|
||||
for response in [
|
||||
RenameDataResponse {
|
||||
success: true,
|
||||
error: Some(DiskError::FileNotFound.into()),
|
||||
..Default::default()
|
||||
},
|
||||
RenameDataResponse::default(),
|
||||
RenameDataResponse {
|
||||
success: false,
|
||||
error: Some(rustfs_protos::proto_gen::node_service::Error {
|
||||
code: 0,
|
||||
error_info: secret.to_string(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(matches!(validate_multipart_publication_envelope(&response), Err(DiskError::FileCorrupt)));
|
||||
}
|
||||
let response = RenameDataResponse {
|
||||
success: false,
|
||||
error: Some(DiskError::ConditionalDeleteConflict.into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
validate_multipart_publication_envelope(&response),
|
||||
Err(DiskError::ConditionalDeleteConflict)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_source_recovery_rejects_malformed_and_unrelated_replies() {
|
||||
use crate::disk::multipart_source::MultipartSourceInspection;
|
||||
let source = MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: "upload".to_string(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
};
|
||||
let result = MultipartSourceResponse::Inspected(MultipartSourceInspection {
|
||||
disk_id: Uuid::new_v4(),
|
||||
snapshot: None,
|
||||
original: None,
|
||||
recovery: None,
|
||||
negative_certificate: None,
|
||||
ready_certificate: None,
|
||||
terminal_certificate: Vec::new(),
|
||||
root_missing: false,
|
||||
abort_round: None,
|
||||
abort_certificate: None,
|
||||
});
|
||||
let valid = RecoverMultipartSourceRpcResponse {
|
||||
success: true,
|
||||
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
|
||||
result: rmp_serde::to_vec_named(&result).expect("inspection reply").into(),
|
||||
error: None,
|
||||
};
|
||||
assert!(decode_multipart_source_recovery_response(&source, valid.clone()).is_ok());
|
||||
let claim = multipart_source_claim_request();
|
||||
let MultipartSourceOperation::Claim { mut expected, .. } = claim.operation else { unreachable!() };
|
||||
expected.lineage.path = "other-upload".to_string();
|
||||
let wrong_scope = MultipartSourceResponse::Inspected(MultipartSourceInspection {
|
||||
disk_id: expected.disk_id,
|
||||
snapshot: Some(expected),
|
||||
original: None,
|
||||
recovery: None,
|
||||
negative_certificate: None,
|
||||
ready_certificate: None,
|
||||
terminal_certificate: Vec::new(),
|
||||
root_missing: false,
|
||||
abort_round: None,
|
||||
abort_certificate: None,
|
||||
});
|
||||
let wrong_scope = RecoverMultipartSourceRpcResponse {
|
||||
result: rmp_serde::to_vec_named(&wrong_scope).expect("unrelated inspection").into(),
|
||||
..valid.clone()
|
||||
};
|
||||
assert!(decode_multipart_source_recovery_response(&source, wrong_scope).is_err());
|
||||
let mut wrong_version = valid.clone();
|
||||
wrong_version.protocol_version += 1;
|
||||
assert!(matches!(
|
||||
decode_multipart_source_recovery_response(&source, wrong_version),
|
||||
Err(DiskError::UnsupportedDisk)
|
||||
));
|
||||
for invalid in [
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
error: Some(DiskError::FileNotFound.into()),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
error: None,
|
||||
result: Bytes::new(),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
error: Some(rustfs_protos::proto_gen::node_service::Error {
|
||||
code: 0,
|
||||
error_info: "invalid code".to_string(),
|
||||
}),
|
||||
result: Bytes::new(),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
error: Some(DiskError::FileNotFound.into()),
|
||||
..valid.clone()
|
||||
},
|
||||
RecoverMultipartSourceRpcResponse {
|
||||
result: Bytes::from_static(b"invalid-msgpack"),
|
||||
..valid.clone()
|
||||
},
|
||||
{
|
||||
let mut trailing = valid;
|
||||
let mut bytes = trailing.result.to_vec();
|
||||
bytes.push(0xc0);
|
||||
trailing.result = bytes.into();
|
||||
trailing
|
||||
},
|
||||
] {
|
||||
assert!(decode_multipart_source_recovery_response(&source, invalid).is_err());
|
||||
}
|
||||
let native_error = RecoverMultipartSourceRpcResponse {
|
||||
success: false,
|
||||
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
|
||||
result: Bytes::new(),
|
||||
error: Some(DiskError::ConditionalDeleteConflict.into()),
|
||||
};
|
||||
assert!(matches!(
|
||||
decode_multipart_source_recovery_response(&source, native_error),
|
||||
Err(DiskError::ConditionalDeleteConflict)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_source_recovery_does_not_accept_caller_constructed_claim_proofs() {
|
||||
use crate::disk::multipart_source::{MultipartProofKind, MultipartSourcePhase, MultipartSourceProof};
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let request = multipart_source_claim_request();
|
||||
let MultipartSourceOperation::Claim { expected, round } = &request.operation else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut snapshot = expected.clone();
|
||||
snapshot.generation = round.generation;
|
||||
snapshot.phase = MultipartSourcePhase::Recovering;
|
||||
snapshot.revision += 1;
|
||||
for authentication in [
|
||||
serde_json::json!({ "class": "cluster_hmac", "mac": vec![0_u8; 32] }),
|
||||
serde_json::json!({ "class": "native_local" }),
|
||||
] {
|
||||
let proof: MultipartSourceProof = serde_json::from_value(serde_json::json!({
|
||||
"kind": MultipartProofKind::Claimed,
|
||||
"challenge": round.round,
|
||||
"recovery": round,
|
||||
"snapshot": snapshot,
|
||||
"authentication": authentication,
|
||||
}))
|
||||
.expect("untrusted wire fields can decode without gaining native authority");
|
||||
let response = RecoverMultipartSourceRpcResponse {
|
||||
success: true,
|
||||
protocol_version: u32::from(MULTIPART_SOURCE_PROTOCOL_VERSION),
|
||||
result: rmp_serde::to_vec_named(&MultipartSourceResponse::Claimed(proof))
|
||||
.expect("forged claim fields")
|
||||
.into(),
|
||||
error: None,
|
||||
};
|
||||
assert!(
|
||||
decode_multipart_source_recovery_response(&request, response).is_err(),
|
||||
"matching caller fields cannot mint a native H vote"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_rename_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let file_info = sample_rename_data_file_info();
|
||||
for scanner_lease in [None, Some(Uuid::new_v4())] {
|
||||
assert_eq!(
|
||||
remote
|
||||
.rename_data_borrowed_with_fence(
|
||||
crate::disk::RUSTFS_META_MULTIPART_BUCKET,
|
||||
"upload",
|
||||
&file_info,
|
||||
"bucket",
|
||||
"object",
|
||||
scanner_lease,
|
||||
)
|
||||
.await
|
||||
.expect_err("old peer cannot preserve multipart source data"),
|
||||
DiskError::UnsupportedDisk,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
remote
|
||||
.rename_data("tmp", "source", file_info, "bucket", "object")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec![
|
||||
"/node_service.NodeService/RenameMultipartData",
|
||||
"/node_service.NodeService/RenameMultipartData",
|
||||
"/node_service.NodeService/RenameData",
|
||||
],
|
||||
"each dispatch must use exactly one method; multipart publication must never downgrade",
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_fence_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
@@ -4607,6 +5156,153 @@ mod tests {
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_recovery_uses_bulk_channel_without_replacing_control() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
temp_env::async_with_vars(
|
||||
[
|
||||
(rustfs_config::ENV_INTERNODE_CHANNEL_ISOLATION, Some("true")),
|
||||
(rustfs_config::ENV_INTERNODE_BULK_CHANNELS, Some("1")),
|
||||
],
|
||||
async {
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("route test peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let bulk_key = format!("{}\u{0}bulk\u{0}0", peer.addr);
|
||||
drop(remote.get_client().await.expect("control channel must connect"));
|
||||
assert!(rustfs_common::has_cached_connection(&peer.addr).await, "control channel must be warm");
|
||||
assert!(
|
||||
!rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"the unique peer must have no bulk channel yet"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
remote
|
||||
.pool_meta_native_recovery(PoolMetaNativeRecoveryRequest::Probe {})
|
||||
.await,
|
||||
Err(DiskError::UnsupportedDisk),
|
||||
"the old-peer response must still traverse the real recovery RPC route",
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&peer.addr).await,
|
||||
"recovery must retain the control cache"
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"recovery must establish its isolated bulk channel"
|
||||
);
|
||||
for request in [
|
||||
MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: "upload".to_owned(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
},
|
||||
multipart_source_claim_request(),
|
||||
] {
|
||||
rustfs_common::evict_connection(&bulk_key).await;
|
||||
assert!(
|
||||
!rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"each multipart recovery must establish its own observed bulk route"
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.recover_multipart_source(request)
|
||||
.await
|
||||
.expect_err("old peer must reject before recovery work"),
|
||||
DiskError::UnsupportedDisk,
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&peer.addr).await,
|
||||
"multipart recovery must retain the control channel"
|
||||
);
|
||||
assert!(
|
||||
rustfs_common::has_cached_connection(&bulk_key).await,
|
||||
"multipart inspection and mutation must both establish an isolated bulk channel"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec![
|
||||
"/node_service.NodeService/PoolMetaNativeRecovery",
|
||||
"/node_service.NodeService/RecoverMultipartSource",
|
||||
"/node_service.NodeService/RecoverMultipartSource",
|
||||
],
|
||||
);
|
||||
|
||||
remote.cancel_token.cancel();
|
||||
drop(remote);
|
||||
rustfs_common::evict_connection(&peer.addr).await;
|
||||
rustfs_common::evict_connection(&bulk_key).await;
|
||||
peer.stop().await;
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
|
||||
.await
|
||||
.expect("old peer must bind");
|
||||
let remote = remote_disk_for_addr(&peer.addr).await;
|
||||
let mut file_info = sample_rename_data_file_info();
|
||||
crate::disk::PoolMetaNativeCasCondition {
|
||||
attempt_id: Uuid::new_v4(),
|
||||
parent: None,
|
||||
parent_raw_sha256: None,
|
||||
}
|
||||
.insert_into(&mut file_info.metadata)
|
||||
.expect("native condition must encode");
|
||||
assert_eq!(
|
||||
remote
|
||||
.rename_data_borrowed_with_fence(
|
||||
crate::disk::RUSTFS_META_TMP_BUCKET,
|
||||
"staged",
|
||||
&file_info,
|
||||
crate::disk::RUSTFS_META_BUCKET,
|
||||
"pool.bin",
|
||||
None
|
||||
)
|
||||
.await
|
||||
.expect_err("old peer must reject native CAS"),
|
||||
DiskError::UnsupportedDisk,
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.delete_version(
|
||||
crate::disk::RUSTFS_META_BUCKET,
|
||||
"pool.bin",
|
||||
file_info,
|
||||
false,
|
||||
DeleteOptions {
|
||||
undo_write: true,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.await,
|
||||
Err(DiskError::UnsupportedDisk),
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.pool_meta_native_recovery(PoolMetaNativeRecoveryRequest::Probe {})
|
||||
.await,
|
||||
Err(DiskError::UnsupportedDisk),
|
||||
);
|
||||
assert_eq!(
|
||||
*peer.peer.unsupported_methods.lock().expect("observed methods lock"),
|
||||
vec![
|
||||
"/node_service.NodeService/RenameDataWithPoolMetaCas",
|
||||
"/node_service.NodeService/UndoPoolMetaWrite",
|
||||
"/node_service.NodeService/PoolMetaNativeRecovery"
|
||||
],
|
||||
"protected mutations and capability probes must never fall back to legacy RPCs",
|
||||
);
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guarded_delete_old_peer_rejects_without_legacy_fallback() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
@@ -5494,6 +6190,7 @@ mod tests {
|
||||
fn rename_data_response_accepts_legacy_json_without_decode_error() {
|
||||
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
|
||||
let response = RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
@@ -5898,6 +6595,7 @@ mod tests {
|
||||
#[test]
|
||||
fn rename_data_resp_named_msgpack_is_smaller_than_json() {
|
||||
let response = RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir: Some(Uuid::new_v4()),
|
||||
rollback_data_dir: Some(Uuid::new_v4()),
|
||||
cleanup_data_dir: Some(Uuid::new_v4()),
|
||||
@@ -8050,6 +8748,89 @@ mod tests {
|
||||
peer.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(remote_disk_recovery_probe)]
|
||||
async fn endpoint_reconnect_admits_ready_drive_while_another_format_probe_is_blocked() {
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let reference = crate::layout::format::FormatV3::new(1, 2);
|
||||
let mut peers = Vec::new();
|
||||
let mut endpoints = Vec::new();
|
||||
for index in 0..2 {
|
||||
let mut format = reference.clone();
|
||||
format.erasure.this = reference.erasure.sets[0][index];
|
||||
let peer = TestGrpcPeer::spawn(
|
||||
Bytes::from(format.to_json().expect("format should serialize")),
|
||||
Bytes::from_static(b"recovered-peer-data"),
|
||||
)
|
||||
.await
|
||||
.expect("authenticated regression peer is required");
|
||||
endpoints.push(Endpoint {
|
||||
url: url::Url::parse(&format!("{}/data/rustfs{index}", peer.addr)).expect("endpoint should parse"),
|
||||
is_local: false,
|
||||
pool_idx: 0,
|
||||
set_idx: 0,
|
||||
disk_idx: i32::try_from(index).expect("fixture index fits"),
|
||||
});
|
||||
peers.push(peer);
|
||||
}
|
||||
let set = crate::set_disk::SetDisks::new(
|
||||
"independent-reconnect-test".to_owned(),
|
||||
Arc::new(tokio::sync::RwLock::new(vec![None; 2])),
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
endpoints,
|
||||
reference.clone(),
|
||||
Vec::new(),
|
||||
)
|
||||
.await;
|
||||
let gate = peers[0].peer.pause_next_format_read();
|
||||
let pending_set = Arc::clone(&set);
|
||||
let pending = tokio::spawn(async move { pending_set.connect_disks().await });
|
||||
time::timeout(Duration::from_secs(10), gate.entered.notified())
|
||||
.await
|
||||
.expect("the first endpoint must reach the format barrier");
|
||||
|
||||
let ready = time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Some(disk) = set.disks.read().await[1].clone() {
|
||||
return disk;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if let Ok(disk) = &ready {
|
||||
assert!(set.disks.read().await[0].is_none(), "the blocked endpoint must remain unpublished");
|
||||
assert!(!pending.is_finished(), "the sweep still owns its blocked endpoint probe");
|
||||
assert_eq!(
|
||||
disk.read_all("bucket", "object")
|
||||
.await
|
||||
.expect("the ready endpoint must serve real I/O"),
|
||||
Bytes::from_static(b"recovered-peer-data")
|
||||
);
|
||||
assert_eq!(
|
||||
peers[1].peer.object_read_all_disks(),
|
||||
vec![reference.erasure.sets[0][1].to_string()],
|
||||
"the usable handle must carry its validated slot identity"
|
||||
);
|
||||
}
|
||||
gate.release.cancel();
|
||||
pending.await.expect("all endpoint probes must finish after release");
|
||||
for disk in set.disks.read().await.iter().flatten() {
|
||||
disk.close().await.expect("stop each published handle's monitoring");
|
||||
}
|
||||
for peer in peers {
|
||||
peer.stop().await;
|
||||
}
|
||||
ready.expect("a blocked earlier endpoint must not delay a ready drive in the same set");
|
||||
assert!(
|
||||
set.disks.read().await.iter().all(Option::is_some),
|
||||
"both endpoints must eventually be admitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(remote_disk_recovery_probe)]
|
||||
async fn missing_read_disks_preserve_concurrently_published_handles() {
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
// 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.
|
||||
|
||||
use super::*;
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
struct PhysicalFixture {
|
||||
_directories: Vec<tempfile::TempDir>,
|
||||
store: Arc<ECStore>,
|
||||
canonical: Vec<u8>,
|
||||
pending: Vec<u8>,
|
||||
identity: Vec<u8>,
|
||||
revision: PoolMetaRevision,
|
||||
}
|
||||
|
||||
fn object_path(disk: &DiskStore) -> PathBuf {
|
||||
disk.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||
.expect("physical fixture must use local disks")
|
||||
.expect("pool metadata path should resolve")
|
||||
}
|
||||
|
||||
async fn disks(store: &ECStore) -> Vec<Vec<DiskStore>> {
|
||||
let mut all = Vec::with_capacity(store.pools.len());
|
||||
for pool in &store.pools {
|
||||
let set = pool.get_disks_by_key(POOL_META_NAME);
|
||||
let disks = set
|
||||
.disks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|disk| disk.expect("physical fixture must keep every disk online"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(disks.len(), 4, "fixture requires an EC 2+2 set");
|
||||
all.push(disks);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
async fn owner(store: &ECStore) -> Arc<rustfs_lock::NamespaceLockGuard> {
|
||||
let lock = store.pools[0]
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||
.await
|
||||
.expect("real namespace lock should be created");
|
||||
Arc::new(
|
||||
lock.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("physical recovery must hold the namespace writer"),
|
||||
)
|
||||
}
|
||||
|
||||
fn clean_state(fixture: &PhysicalFixture) -> PoolMetaWriteState {
|
||||
PoolMetaWriteState::for_startup(fixture.revision.cluster_id.expect("fixture V3 revision requires identity"), false)
|
||||
}
|
||||
|
||||
async fn snapshots(store: &ECStore) -> Vec<Vec<PoolMetaNativeInspection>> {
|
||||
let mut all = Vec::new();
|
||||
for pool in disks(store).await {
|
||||
let mut current = Vec::new();
|
||||
for disk in pool {
|
||||
match disk
|
||||
.pool_meta_native_recovery(Request::Inspect {
|
||||
object: PoolMetaNativeObject::PoolBin,
|
||||
})
|
||||
.await
|
||||
.expect("physical native inspection should succeed")
|
||||
{
|
||||
Response::Inspected(inspection) => current.push(inspection),
|
||||
_ => panic!("inspection must return a disk snapshot"),
|
||||
}
|
||||
}
|
||||
all.push(current);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
async fn fixture(split_pools: usize) -> PhysicalFixture {
|
||||
let (directories, store, _other_store) =
|
||||
crate::services::rebalance::test_three_pool_stores_with_four_disk_sets_with_isolated_node_contexts(None).await;
|
||||
persist_v3_pool_meta_for_test(&store).await;
|
||||
let previous = load_pool_meta_replicas(store.pools.clone(), true)
|
||||
.await
|
||||
.expect("baseline V3 pool metadata must be ordinary-readable");
|
||||
let revision = PoolMetaRevision {
|
||||
generation: previous
|
||||
.revision
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.expect("test generation should increment"),
|
||||
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||
..previous.revision
|
||||
};
|
||||
let candidate = PoolMetaCommittedCandidate {
|
||||
canonical: previous.canonical.expect("baseline must have a canonical V3 COMMIT"),
|
||||
meta: previous.meta.clone(),
|
||||
revision: previous.revision,
|
||||
};
|
||||
let canonical = encode_pool_meta_v3_envelope(&previous.meta, revision, true, None).expect("next COMMIT should encode");
|
||||
let pending =
|
||||
encode_pool_meta_v3_envelope(&previous.meta, revision, false, Some(&candidate)).expect("next PREPARE should encode");
|
||||
let identity = load_pool_meta_identity(
|
||||
store.pools.clone(),
|
||||
revision
|
||||
.cluster_id
|
||||
.expect("baseline V3 metadata should have a cluster identity"),
|
||||
)
|
||||
.await
|
||||
.expect("real initialized identities must agree")
|
||||
.identity
|
||||
.expect("real fixture identity must be present");
|
||||
let identity = encode_pool_meta_identity(identity).expect("real fixture identity should encode canonically");
|
||||
let modified = OffsetDateTime::now_utc();
|
||||
let split = split(&pending, &canonical, modified).await;
|
||||
let commit = replicas(&canonical, modified + Duration::seconds(1)).await;
|
||||
let prepared = replicas(&pending, modified).await;
|
||||
let directory = uuid::Uuid::new_v4();
|
||||
for (pool_index, pool) in disks(&store).await.into_iter().enumerate() {
|
||||
let heads = if pool_index < split_pools { &split } else { &commit };
|
||||
for (disk_index, disk) in pool.into_iter().enumerate() {
|
||||
let object = object_path(&disk);
|
||||
fs::remove_dir_all(&object)
|
||||
.await
|
||||
.expect("fault injection should remove only its own pool.bin fixture");
|
||||
fs::create_dir_all(object.join(directory.to_string()))
|
||||
.await
|
||||
.expect("fixture rollback directory should be created");
|
||||
fs::write(object.join("xl.meta"), &heads[disk_index])
|
||||
.await
|
||||
.expect("physical split head should be installed");
|
||||
fs::write(object.join(directory.to_string()).join("xl.meta.bkp"), &prepared[disk_index])
|
||||
.await
|
||||
.expect("physical PREPARE backup should be installed");
|
||||
}
|
||||
}
|
||||
PhysicalFixture {
|
||||
_directories: directories,
|
||||
store,
|
||||
canonical,
|
||||
pending,
|
||||
identity,
|
||||
revision,
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_committed(fixture: &PhysicalFixture) {
|
||||
let confirmed = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||
.await
|
||||
.expect("ordinary full-pool read must succeed after physical recovery");
|
||||
assert_eq!(confirmed.canonical.as_deref(), Some(fixture.canonical.as_slice()));
|
||||
assert_eq!(confirmed.revision, fixture.revision);
|
||||
assert!(!confirmed.replica_state.needs_repair);
|
||||
assert!(confirmed.replica_state.repair_write_safe);
|
||||
for pool in snapshots(&fixture.store).await {
|
||||
let raw = pool
|
||||
.iter()
|
||||
.map(|disk| disk.current.clone().expect("every recovered physical head must exist"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
codec::decode_inline_pool_meta_replicas(&raw)
|
||||
.await
|
||||
.expect("every recovered shard must verify"),
|
||||
vec![Bytes::copy_from_slice(&fixture.canonical)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(pool_meta_version_env)]
|
||||
async fn pool_meta_native_physical_split_repair_completes_normal_cas_and_quorum_readback() {
|
||||
let fixture = fixture(1).await;
|
||||
let before = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
|
||||
assert!(matches!(before[0].replica, PoolMetaReplica::Unreadable(_)));
|
||||
assert!(matches!(before[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
|
||||
let mut state = clean_state(&fixture);
|
||||
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||
assert!(
|
||||
repair(&fixture.store, &mut state, &fence)
|
||||
.await
|
||||
.expect("healthy ordinary COMMIT must authorize physical repair")
|
||||
);
|
||||
state
|
||||
.ensure_write_safe("physical recovery readback")
|
||||
.expect("completed recovery must disarm its transaction");
|
||||
assert_committed(&fixture).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(pool_meta_version_env)]
|
||||
async fn pool_meta_native_physical_split_without_ordinary_commit_performs_no_mutation() {
|
||||
let fixture = fixture(3).await;
|
||||
let before = snapshots(&fixture.store).await;
|
||||
let mut state = clean_state(&fixture);
|
||||
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||
assert!(
|
||||
repair(&fixture.store, &mut state, &fence).await.is_err(),
|
||||
"reconstruction alone must never authorize a COMMIT"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshots(&fixture.store).await,
|
||||
before,
|
||||
"all current heads, identities and complete backups must remain unchanged"
|
||||
);
|
||||
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
struct IdentityEpochFork {
|
||||
path: PathBuf,
|
||||
original: Bytes,
|
||||
replacement: Bytes,
|
||||
original_identity: PersistedPoolMetaIdentity,
|
||||
}
|
||||
|
||||
async fn identity_epoch_fork(fixture: &PhysicalFixture) -> IdentityEpochFork {
|
||||
let identity_set = fixture.store.pools[0].get_disks_by_key(POOL_META_IDENTITY_NAME);
|
||||
assert!(
|
||||
!Arc::ptr_eq(&identity_set, &fixture.store.pools[0].get_disks_by_key(POOL_META_NAME)),
|
||||
"the identity evidence must come from its independently placed physical set"
|
||||
);
|
||||
let identity_disks = identity_set
|
||||
.disks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|disk| disk.expect("actual identity set must retain all four disks"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut raw = Vec::with_capacity(identity_disks.len());
|
||||
for disk in &identity_disks {
|
||||
match disk
|
||||
.pool_meta_native_recovery(Request::InspectIdentity {})
|
||||
.await
|
||||
.expect("actual identity set must support native inspection")
|
||||
{
|
||||
Response::IdentityInspected { disk_id, raw: current } => {
|
||||
assert!(!disk_id.is_nil());
|
||||
raw.push(current);
|
||||
}
|
||||
_ => panic!("identity inspection must return its physical raw shard"),
|
||||
}
|
||||
}
|
||||
assert_eq!(raw.len(), 4);
|
||||
let original_identity = match decode_pool_meta_identity(&fixture.identity) {
|
||||
PoolMetaIdentityReplica::Valid(identity) => identity,
|
||||
_ => panic!("fixture must provide a valid initialized identity"),
|
||||
};
|
||||
let forked_identity = PersistedPoolMetaIdentity {
|
||||
epoch: original_identity
|
||||
.epoch
|
||||
.checked_add(1)
|
||||
.expect("test identity epoch should increment"),
|
||||
..original_identity
|
||||
};
|
||||
let forked = encode_pool_meta_identity(forked_identity).expect("foreign epoch identity should remain validly encoded");
|
||||
assert!(matches!(decode_pool_meta_identity(&forked), PoolMetaIdentityReplica::Valid(_)));
|
||||
let modified = raw
|
||||
.iter()
|
||||
.map(|raw| {
|
||||
codec::parse_inline_info(raw)
|
||||
.expect("actual identity shard layout should parse")
|
||||
.mod_time
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.expect("every actual identity shard should have a modification time")
|
||||
.into_iter()
|
||||
.max()
|
||||
.expect("actual identity set should contain four shards")
|
||||
+ Duration::nanoseconds(1);
|
||||
let replacements = codec::encode_inline_pool_meta_replicas(&raw, &forked, modified)
|
||||
.await
|
||||
.expect("epoch fork must retain the actual set's valid erasure layout");
|
||||
assert_eq!(
|
||||
codec::parse_inline_info(&raw[0]).expect("original layout").erasure,
|
||||
codec::parse_inline_info(&replacements[0])
|
||||
.expect("replacement layout")
|
||||
.erasure
|
||||
);
|
||||
codec::validate_inline_pool_meta_shard(&replacements[0], 0, &forked)
|
||||
.await
|
||||
.expect("forked shard must pass the physical integrity checks");
|
||||
let identity_path = identity_disks[0]
|
||||
.get_object_path_if_local(RUSTFS_META_BUCKET, POOL_META_IDENTITY_NAME)
|
||||
.expect("identity fixture must use local storage")
|
||||
.expect("actual identity metadata path should resolve");
|
||||
IdentityEpochFork {
|
||||
path: identity_path.join("xl.meta"),
|
||||
original: raw[0].clone(),
|
||||
replacement: replacements[0].clone(),
|
||||
original_identity,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(pool_meta_version_env)]
|
||||
async fn pool_meta_native_physical_identity_actual_set_minority_epoch_fork_performs_no_apply() {
|
||||
let fixture = fixture(1).await;
|
||||
let fork = identity_epoch_fork(&fixture).await;
|
||||
let heads = |pools: Vec<Vec<PoolMetaNativeInspection>>| {
|
||||
pools
|
||||
.into_iter()
|
||||
.map(|pool| pool.into_iter().map(|disk| (disk.current, disk.backups)).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let before = heads(snapshots(&fixture.store).await);
|
||||
fs::write(&fork.path, &fork.replacement)
|
||||
.await
|
||||
.expect("only one actual identity-set disk should receive the epoch fork");
|
||||
let selected = load_pool_meta_identity(fixture.store.pools.clone(), fork.original_identity.cluster_id)
|
||||
.await
|
||||
.expect("three matching identity shards must remain ordinary-readable");
|
||||
assert_eq!(selected.identity, Some(fork.original_identity));
|
||||
let reads = read_pool_meta_replicas(fixture.store.pools.clone(), true).await;
|
||||
assert!(matches!(reads[0].replica, PoolMetaReplica::Unreadable(_)));
|
||||
assert!(matches!(reads[1].replica, PoolMetaReplica::Valid { committed: true, .. }));
|
||||
let mut state = clean_state(&fixture);
|
||||
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||
let error = repair(&fixture.store, &mut state, &fence)
|
||||
.await
|
||||
.expect_err("ordinary identity quorum must not hide a physical minority epoch fork");
|
||||
assert_eq!(
|
||||
error
|
||||
.pool_metadata_failure()
|
||||
.expect("known identity fork must retain typed failure")
|
||||
.kind,
|
||||
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||
);
|
||||
assert!(state.write_blocked, "known minority fork must latch the write gate");
|
||||
assert_eq!(
|
||||
heads(snapshots(&fixture.store).await),
|
||||
before,
|
||||
"identity rejection must precede every pool.bin Apply"
|
||||
);
|
||||
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||
fs::write(&fork.path, &fork.original)
|
||||
.await
|
||||
.expect("restoring the input identity must not clear the observed fork latch");
|
||||
let error = repair(&fixture.store, &mut state, &fence)
|
||||
.await
|
||||
.expect_err("the same state must retain operator recovery after identity becomes consistent");
|
||||
assert_eq!(
|
||||
error
|
||||
.pool_metadata_failure()
|
||||
.expect("sticky failure must retain its classification")
|
||||
.kind,
|
||||
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||
);
|
||||
assert!(state.write_blocked);
|
||||
assert_eq!(heads(snapshots(&fixture.store).await), before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(pool_meta_version_env)]
|
||||
async fn pool_meta_native_physical_runtime_fork_after_apply_latches_original_gate() {
|
||||
let fixture = fixture(1).await;
|
||||
let fork = identity_epoch_fork(&fixture).await;
|
||||
let since = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("fixed original failure timestamp should be valid");
|
||||
{
|
||||
let mut state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
|
||||
state.write_blocked = false;
|
||||
state.block_context = None;
|
||||
state.aborted_transaction.store(true, Ordering::SeqCst);
|
||||
let mut failure = pool_metadata_error(crate::error::PoolMetadataFailure::TransactionUnknown, "commit_cas", None);
|
||||
failure.since = since;
|
||||
*state
|
||||
.transaction_failure
|
||||
.lock()
|
||||
.expect("original transaction failure mutex should lock") = Some(failure);
|
||||
*state
|
||||
.block_started_at
|
||||
.lock()
|
||||
.expect("original block timestamp mutex should lock") = Some(since);
|
||||
}
|
||||
let pause = Arc::new(NativeApplyPause {
|
||||
reached: tokio::sync::Notify::new(),
|
||||
resume: tokio::sync::Notify::new(),
|
||||
});
|
||||
let reached = pause.reached.notified();
|
||||
tokio::pin!(reached);
|
||||
reached.as_mut().enable();
|
||||
let recovery = tokio::spawn({
|
||||
let store = Arc::clone(&fixture.store);
|
||||
let pause = Arc::clone(&pause);
|
||||
async move {
|
||||
AFTER_NATIVE_APPLY
|
||||
.scope(pause, async move { store.recover_pool_meta_transaction().await })
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), reached)
|
||||
.await
|
||||
.expect("real runtime recovery should pause after completing physical Apply");
|
||||
let readable = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||
.await
|
||||
.expect("physical Apply must already make every pool ordinary-readable");
|
||||
assert_eq!(readable.canonical.as_deref(), Some(fixture.canonical.as_slice()));
|
||||
fs::write(&fork.path, &fork.replacement)
|
||||
.await
|
||||
.expect("a valid minority identity fork should arrive after physical repair");
|
||||
pause.resume.notify_one();
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(10), recovery)
|
||||
.await
|
||||
.expect("resumed runtime recovery should finish its validation")
|
||||
.expect("runtime recovery task should not panic")
|
||||
.expect_err("post-Apply identity fork must reject runtime recovery");
|
||||
assert_eq!(
|
||||
error
|
||||
.pool_metadata_failure()
|
||||
.expect("runtime fork failure must remain typed")
|
||||
.kind,
|
||||
crate::error::PoolMetadataFailure::RecoveryRequired
|
||||
);
|
||||
assert_committed(&fixture).await;
|
||||
{
|
||||
let state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
assert!(
|
||||
state.write_blocked,
|
||||
"candidate validation must propagate its permanent latch to the original state"
|
||||
);
|
||||
let context = state
|
||||
.block_context
|
||||
.as_ref()
|
||||
.expect("original state must retain the known fork context");
|
||||
assert_eq!(context.kind, crate::error::PoolMetadataFailure::RecoveryRequired);
|
||||
assert_eq!(context.since, since, "candidate failure must preserve the original outage timestamp");
|
||||
assert_eq!(*state.block_started_at.lock().expect("original block timestamp should lock"), Some(since));
|
||||
}
|
||||
assert!(
|
||||
!fixture
|
||||
.store
|
||||
.recover_pool_meta_transaction()
|
||||
.await
|
||||
.expect("permanently blocked recovery should return without mutation"),
|
||||
"ordinary-readable repaired heads must not clear the original known-fork latch"
|
||||
);
|
||||
let state = fixture.store.pool_meta_save_gate.lock().await;
|
||||
assert!(state.write_blocked);
|
||||
assert_eq!(
|
||||
state
|
||||
.block_context
|
||||
.as_ref()
|
||||
.expect("sticky fork context must remain present")
|
||||
.since,
|
||||
since
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(pool_meta_version_env)]
|
||||
async fn pool_meta_native_physical_partial_apply_resumes_after_conditional_rejection() {
|
||||
let fixture = fixture(1).await;
|
||||
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||
let inspected = inspect(&fixture.store.pools, &fence)
|
||||
.await
|
||||
.expect("real native snapshots should be obtained");
|
||||
let replacements = plan(&inspected, &fixture.canonical, fixture.revision, &fixture.identity)
|
||||
.await
|
||||
.expect("physical recovery plan should validate");
|
||||
let snapshot = &inspected[0].snapshots[0];
|
||||
assert_eq!(
|
||||
inspected[0].disks[0]
|
||||
.pool_meta_native_recovery(Request::Apply {
|
||||
object: PoolMetaNativeObject::PoolBin,
|
||||
expected_disk_id: snapshot.disk_id,
|
||||
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
|
||||
expected_backup_inventory_sha256: snapshot.inventory_sha256().expect("snapshot inventory should hash"),
|
||||
replacement: replacements[0][0].clone(),
|
||||
})
|
||||
.await
|
||||
.expect("first real Apply must atomically publish and sync"),
|
||||
Response::Applied
|
||||
);
|
||||
let before_rejected = snapshots(&fixture.store).await;
|
||||
let snapshot = &inspected[0].snapshots[1];
|
||||
let mut stale_inventory = snapshot.inventory_sha256().expect("snapshot inventory should hash");
|
||||
stale_inventory[0] ^= 1;
|
||||
assert!(matches!(
|
||||
inspected[0].disks[1]
|
||||
.pool_meta_native_recovery(Request::Apply {
|
||||
object: PoolMetaNativeObject::PoolBin,
|
||||
expected_disk_id: snapshot.disk_id,
|
||||
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
|
||||
expected_backup_inventory_sha256: stale_inventory,
|
||||
replacement: replacements[0][1].clone(),
|
||||
})
|
||||
.await,
|
||||
Err(DiskError::ConditionalDeleteConflict)
|
||||
));
|
||||
assert_eq!(
|
||||
snapshots(&fixture.store).await,
|
||||
before_rejected,
|
||||
"rejected Apply must preserve the partial repair evidence"
|
||||
);
|
||||
drop(inspected);
|
||||
drop(fence);
|
||||
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||
let mut state = clean_state(&fixture);
|
||||
assert!(
|
||||
repair(&fixture.store, &mut state, &fence)
|
||||
.await
|
||||
.expect("a fresh owner should resume the partial physical repair")
|
||||
);
|
||||
assert_committed(&fixture).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(pool_meta_version_env)]
|
||||
async fn pool_meta_native_physical_noninline_conditional_io_round_trip() {
|
||||
let fixture = fixture(0).await;
|
||||
let pool = Arc::clone(&fixture.store.pools[0]);
|
||||
let fence = PoolMetaPersistenceFence::Owned(owner(&fixture.store).await);
|
||||
let (original, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
|
||||
.await
|
||||
.expect("baseline conditional object must be readable");
|
||||
let token = pool_meta_cas_token_from_info(info);
|
||||
// This smoke test targets the physical conditional I/O boundary. The large
|
||||
// opaque body deliberately exceeds the inline writer's one-block limit.
|
||||
let payload = vec![0x5a; rustfs_filemeta::BLOCK_SIZE_V2 + 137];
|
||||
let state = clean_state(&fixture);
|
||||
let mut arm = state.arm_transaction();
|
||||
save_pool_meta_object_cas(
|
||||
Arc::clone(&pool),
|
||||
POOL_META_NAME,
|
||||
payload.clone(),
|
||||
&token,
|
||||
&fence,
|
||||
"noninline_smoke",
|
||||
&mut arm,
|
||||
)
|
||||
.await
|
||||
.expect("normal native CAS must support noninline pool metadata storage");
|
||||
let (readback, info) = read_config_no_lock_preserve_empty_with_metadata(Arc::clone(&pool), POOL_META_NAME)
|
||||
.await
|
||||
.expect("noninline conditional write must be ordinary-readable");
|
||||
assert_eq!(readback, payload);
|
||||
for disk in &snapshots(&fixture.store).await[0] {
|
||||
let raw = disk
|
||||
.current
|
||||
.as_ref()
|
||||
.expect("noninline head should be installed on every disk");
|
||||
let info = rustfs_filemeta::get_file_info(
|
||||
raw,
|
||||
RUSTFS_META_BUCKET,
|
||||
POOL_META_NAME,
|
||||
"",
|
||||
rustfs_filemeta::FileInfoOpts {
|
||||
data: true,
|
||||
include_free_versions: false,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)
|
||||
.expect("physical noninline metadata should decode");
|
||||
assert!(!info.inline_data());
|
||||
assert!(info.data.is_none());
|
||||
}
|
||||
save_pool_meta_object_cas(
|
||||
pool,
|
||||
POOL_META_NAME,
|
||||
original.clone(),
|
||||
&pool_meta_cas_token_from_info(info),
|
||||
&fence,
|
||||
"noninline_to_inline_smoke",
|
||||
&mut arm,
|
||||
)
|
||||
.await
|
||||
.expect("normal CAS must also replace a noninline parent with inline metadata");
|
||||
arm.disarm();
|
||||
assert_committed(&fixture).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(pool_meta_version_env)]
|
||||
async fn pool_meta_native_physical_three_plus_one_checkpoint_converges_all_shards() {
|
||||
let fixture = fixture(0).await;
|
||||
let pending = replicas(&fixture.pending, OffsetDateTime::now_utc() - Duration::seconds(1)).await;
|
||||
let all_disks = disks(&fixture.store).await;
|
||||
fs::write(object_path(&all_disks[0][0]).join("xl.meta"), &pending[0])
|
||||
.await
|
||||
.expect("one stale physical PREPARE head should be installed");
|
||||
let selection = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||
.await
|
||||
.expect("three matching COMMIT shards must remain ordinary-readable");
|
||||
assert_eq!(selection.canonical.as_deref(), Some(fixture.canonical.as_slice()));
|
||||
let guard = owner(&fixture.store).await;
|
||||
let fence = PoolMetaPersistenceFence::Owned(Arc::clone(&guard));
|
||||
let mut state = clean_state(&fixture);
|
||||
assert!(
|
||||
!repair(&fixture.store, &mut state, &fence)
|
||||
.await
|
||||
.expect("ordinary-readable pools should use normal checkpoint recovery")
|
||||
);
|
||||
selection
|
||||
.meta
|
||||
.save_for_startup_guarded(fixture.store.pools.clone(), &mut state, guard)
|
||||
.await
|
||||
.expect("normal checkpoint must converge a readable 3+1 pool without an unsafe native overwrite");
|
||||
let checkpoint = load_pool_meta_replicas(fixture.store.pools.clone(), true)
|
||||
.await
|
||||
.expect("checkpoint must remain ordinary-readable across all pools");
|
||||
assert!(!checkpoint.replica_state.needs_repair);
|
||||
assert!(checkpoint.replica_state.repair_write_safe);
|
||||
assert!(checkpoint.revision.generation > fixture.revision.generation);
|
||||
let canonical = checkpoint.canonical.expect("checkpoint should expose a canonical COMMIT");
|
||||
for pool in snapshots(&fixture.store).await {
|
||||
let raw = pool
|
||||
.into_iter()
|
||||
.map(|disk| disk.current.expect("checkpoint head must exist"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
codec::decode_inline_pool_meta_replicas(&raw)
|
||||
.await
|
||||
.expect("all four checkpoint shards must verify"),
|
||||
vec![Bytes::copy_from_slice(&canonical)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
// 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.
|
||||
|
||||
use super::pool_meta_recovery_codec as codec;
|
||||
use super::*;
|
||||
use crate::disk::{
|
||||
DiskAPI, DiskStore, PoolMetaNativeInspection, PoolMetaNativeObject, PoolMetaNativeRecoveryRequest as Request,
|
||||
PoolMetaNativeRecoveryResponse as Response,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
pub(super) struct NativeApplyPause {
|
||||
pub(super) reached: tokio::sync::Notify,
|
||||
pub(super) resume: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
tokio::task_local! {
|
||||
pub(super) static AFTER_NATIVE_APPLY: Arc<NativeApplyPause>;
|
||||
}
|
||||
|
||||
struct InspectedPool {
|
||||
disks: Vec<DiskStore>,
|
||||
snapshots: Vec<PoolMetaNativeInspection>,
|
||||
}
|
||||
|
||||
fn invalid() -> Error {
|
||||
Error::other("native pool metadata recovery requires one proven V3 transaction and complete matching disk evidence")
|
||||
}
|
||||
|
||||
fn known_validation(source: Error, phase: &'static str) -> Error {
|
||||
Error::other(pool_metadata_error(
|
||||
crate::error::PoolMetadataFailure::RecoveryRequired,
|
||||
phase,
|
||||
Some(Arc::new(source)),
|
||||
))
|
||||
}
|
||||
|
||||
fn codec_validation(source: codec::PoolMetaCodecError, phase: &'static str) -> Error {
|
||||
// A supported transport may carry a valid layout outside this narrow
|
||||
// recovery codec. That is not evidence of corruption or a transaction fork.
|
||||
if matches!(source, codec::PoolMetaCodecError::UnsupportedLayout) {
|
||||
return Error::other(source);
|
||||
}
|
||||
known_validation(Error::other(source), phase)
|
||||
}
|
||||
|
||||
fn latch_validation(state: &mut PoolMetaWriteState, error: Error) -> Error {
|
||||
if let Some(context) = error.pool_metadata_failure()
|
||||
&& context.kind == crate::error::PoolMetadataFailure::RecoveryRequired
|
||||
{
|
||||
state.block_with_context(context.clone());
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
fn current(pool: &InspectedPool) -> Result<Vec<Bytes>> {
|
||||
pool.snapshots
|
||||
.iter()
|
||||
.map(|snapshot| snapshot.current.clone().ok_or_else(invalid))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn inspect(pools: &[Arc<Sets>], fence: &PoolMetaPersistenceFence<'_>) -> Result<Vec<InspectedPool>> {
|
||||
let mut inspected = Vec::with_capacity(pools.len());
|
||||
// Pool -> disk order is stable; all capability probes precede every mutation.
|
||||
for pool in pools {
|
||||
fence.ensure_held()?;
|
||||
let set = pool.get_disks_by_key(POOL_META_NAME);
|
||||
let disks = set
|
||||
.disks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|disk| disk.ok_or_else(invalid))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let probes = futures::stream::iter(disks.iter().cloned().map(|disk| async move {
|
||||
match disk.pool_meta_native_recovery(Request::Probe {}).await? {
|
||||
Response::Probed => Ok(()),
|
||||
_ => Err(invalid()),
|
||||
}
|
||||
}))
|
||||
.buffered(4)
|
||||
.collect::<Vec<Result<()>>>()
|
||||
.await;
|
||||
for result in probes {
|
||||
result?;
|
||||
}
|
||||
inspected.push(InspectedPool {
|
||||
disks,
|
||||
snapshots: Vec::new(),
|
||||
});
|
||||
}
|
||||
for pool in &mut inspected {
|
||||
fence.ensure_held()?;
|
||||
let results = futures::stream::iter(pool.disks.iter().cloned().map(|disk| async move {
|
||||
match disk
|
||||
.pool_meta_native_recovery(Request::Inspect {
|
||||
object: PoolMetaNativeObject::PoolBin,
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Response::Inspected(snapshot) if !snapshot.disk_id.is_nil() => Ok(snapshot),
|
||||
_ => Err(invalid()),
|
||||
}
|
||||
}))
|
||||
.buffered(4)
|
||||
.collect::<Vec<Result<PoolMetaNativeInspection>>>()
|
||||
.await;
|
||||
for result in results {
|
||||
pool.snapshots.push(result?);
|
||||
}
|
||||
}
|
||||
fence.ensure_held()?;
|
||||
Ok(inspected)
|
||||
}
|
||||
|
||||
async fn validate_identities(pools: &[Arc<Sets>], canonical: &[u8], fence: &PoolMetaPersistenceFence<'_>) -> Result<()> {
|
||||
for pool in pools {
|
||||
fence.ensure_held()?;
|
||||
// Identity and pool.bin have independent placement hashes. Read every
|
||||
// member of the identity's actual set instead of assuming co-location.
|
||||
let set = pool.get_disks_by_key(POOL_META_IDENTITY_NAME);
|
||||
let disks = set
|
||||
.disks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|disk| disk.ok_or_else(invalid))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let results = futures::stream::iter(disks.into_iter().enumerate().map(|(index, disk)| async move {
|
||||
match disk.pool_meta_native_recovery(Request::InspectIdentity {}).await? {
|
||||
Response::IdentityInspected { disk_id, raw } if !disk_id.is_nil() => {
|
||||
codec::validate_inline_pool_meta_shard(&raw, index, canonical)
|
||||
.await
|
||||
.map_err(|error| codec_validation(error, "native_identity_validation"))
|
||||
}
|
||||
_ => Err(invalid()),
|
||||
}
|
||||
}))
|
||||
.buffered(4)
|
||||
.collect::<Vec<Result<()>>>()
|
||||
.await;
|
||||
for result in results {
|
||||
result?;
|
||||
}
|
||||
}
|
||||
fence.ensure_held()
|
||||
}
|
||||
|
||||
async fn plan(
|
||||
inspected: &[InspectedPool],
|
||||
canonical: &[u8],
|
||||
revision: PoolMetaRevision,
|
||||
identity: &[u8],
|
||||
) -> Result<Vec<Vec<Bytes>>> {
|
||||
let mut pending = Vec::<Vec<u8>>::new();
|
||||
let mut predecessors = Vec::<Vec<u8>>::new();
|
||||
let mut decoded = Vec::<Vec<u8>>::new();
|
||||
let mut newest = OffsetDateTime::now_utc();
|
||||
for pool in inspected {
|
||||
let current = current(pool)?;
|
||||
for (index, snapshot) in pool.snapshots.iter().enumerate() {
|
||||
if let Some(local_identity) = snapshot.identity.as_ref() {
|
||||
codec::validate_inline_pool_meta_shard(local_identity, index, identity)
|
||||
.await
|
||||
.map_err(|error| codec_validation(error, "native_metadata_validation"))?;
|
||||
}
|
||||
for raw in std::iter::once(current[index].clone()).chain(snapshot.backups.iter().map(|backup| backup.data.clone())) {
|
||||
let info =
|
||||
codec::parse_inline_info(&raw).map_err(|error| codec_validation(error, "native_metadata_validation"))?;
|
||||
newest = newest.max(info.mod_time.ok_or_else(invalid)?);
|
||||
}
|
||||
}
|
||||
let mut inventories = vec![current.clone()];
|
||||
let directories = pool
|
||||
.snapshots
|
||||
.iter()
|
||||
.flat_map(|snapshot| snapshot.backups.iter().map(|backup| backup.directory))
|
||||
.collect::<HashSet<_>>();
|
||||
for directory in directories {
|
||||
// A missing backup contributes its actual current shard only to
|
||||
// integrity reconstruction. It never invents backup evidence.
|
||||
inventories.push(
|
||||
pool.snapshots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, snapshot)| {
|
||||
snapshot
|
||||
.backups
|
||||
.iter()
|
||||
.find(|backup| backup.directory == directory)
|
||||
.map(|backup| backup.data.clone())
|
||||
.unwrap_or_else(|| current[index].clone())
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
for raw in inventories {
|
||||
for generation in codec::decode_inline_pool_meta_replicas(&raw)
|
||||
.await
|
||||
.map_err(|error| codec_validation(error, "native_metadata_validation"))?
|
||||
{
|
||||
let payload = generation.to_vec();
|
||||
if !decoded.contains(&payload) {
|
||||
decoded.push(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut obsolete = Vec::<Vec<u8>>::new();
|
||||
for payload in &decoded {
|
||||
match decode_pool_meta_replica(payload.clone()) {
|
||||
PoolMetaReplica::Valid {
|
||||
canonical: candidate,
|
||||
revision: candidate_revision,
|
||||
committed: false,
|
||||
previous: Some(previous),
|
||||
..
|
||||
} if candidate_revision == revision && candidate == canonical => {
|
||||
if !pending.contains(payload) {
|
||||
pending.push(payload.clone());
|
||||
}
|
||||
if !predecessors.contains(&previous.canonical) {
|
||||
predecessors.push(previous.canonical);
|
||||
}
|
||||
}
|
||||
PoolMetaReplica::Valid { revision: older, .. }
|
||||
if older.is_generation_protocol()
|
||||
&& older.cluster_id == revision.cluster_id
|
||||
&& older.epoch == revision.epoch
|
||||
&& older.generation < revision.generation =>
|
||||
{
|
||||
// Retained evidence from an earlier successful recovery is
|
||||
// obsolete, not a competing commit authority. It remains
|
||||
// fully reconstructed and bound into the native CAS.
|
||||
obsolete.push(payload.clone());
|
||||
}
|
||||
PoolMetaReplica::Valid {
|
||||
canonical: candidate,
|
||||
revision: candidate_revision,
|
||||
committed: true,
|
||||
..
|
||||
} if candidate_revision == revision && candidate == canonical => {}
|
||||
PoolMetaReplica::Valid { revision: conflict, .. } if conflict.is_generation_protocol() => {
|
||||
return Err(known_validation(
|
||||
Error::other("native V3 transaction conflicts with committed authority"),
|
||||
"native_transaction_validation",
|
||||
));
|
||||
}
|
||||
PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_) => {
|
||||
return Err(known_validation(
|
||||
Error::other("native V3 payload is corrupt or incompatible"),
|
||||
"native_transaction_validation",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if pending.len() > 1 {
|
||||
return Err(known_validation(
|
||||
Error::other("native V3 prepare records disagree on the predecessor"),
|
||||
"native_transaction_validation",
|
||||
));
|
||||
}
|
||||
if pending.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
for payload in &decoded {
|
||||
if payload.as_slice() != canonical
|
||||
&& !pending.contains(payload)
|
||||
&& !predecessors.contains(payload)
|
||||
&& !obsolete.contains(payload)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
for pool in inspected {
|
||||
for (index, snapshot) in pool.snapshots.iter().enumerate() {
|
||||
let raw = snapshot.current.as_ref().ok_or_else(invalid)?;
|
||||
let mut matched = false;
|
||||
for payload in std::iter::once(canonical).chain(pending.iter().map(Vec::as_slice)) {
|
||||
matched |= codec::validate_inline_pool_meta_shard(raw, index, payload).await.is_ok();
|
||||
}
|
||||
if !matched {
|
||||
return Err(invalid());
|
||||
}
|
||||
for backup in &snapshot.backups {
|
||||
let mut matched = false;
|
||||
for payload in std::iter::once(canonical).chain(
|
||||
pending
|
||||
.iter()
|
||||
.chain(predecessors.iter())
|
||||
.chain(obsolete.iter())
|
||||
.map(Vec::as_slice),
|
||||
) {
|
||||
matched |= codec::validate_inline_pool_meta_shard(&backup.data, index, payload)
|
||||
.await
|
||||
.is_ok();
|
||||
}
|
||||
if !matched {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let modified = newest.checked_add(Duration::nanoseconds(1)).ok_or_else(invalid)?;
|
||||
let mut replacements = Vec::with_capacity(inspected.len());
|
||||
for pool in inspected {
|
||||
replacements.push(
|
||||
codec::encode_inline_pool_meta_replicas(¤t(pool)?, canonical, modified)
|
||||
.await
|
||||
.map_err(|error| codec_validation(error, "native_metadata_validation"))?,
|
||||
);
|
||||
}
|
||||
Ok(replacements)
|
||||
}
|
||||
|
||||
pub(super) async fn repair(
|
||||
store: &ECStore,
|
||||
state: &mut PoolMetaWriteState,
|
||||
fence: &PoolMetaPersistenceFence<'_>,
|
||||
) -> Result<bool> {
|
||||
fence.ensure_held()?;
|
||||
let reads = read_pool_meta_replicas(store.pools.clone(), true).await;
|
||||
if !reads
|
||||
.iter()
|
||||
.any(|read| matches!(read.replica, PoolMetaReplica::Unreadable(_)))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
state.ensure_write_safe("native pool metadata recovery")?;
|
||||
if reads
|
||||
.iter()
|
||||
.any(|read| matches!(read.replica, PoolMetaReplica::Corrupt(_) | PoolMetaReplica::Incompatible(_)))
|
||||
{
|
||||
return Err(latch_validation(
|
||||
state,
|
||||
known_validation(Error::other("pool metadata is corrupt or incompatible"), "native_metadata_validation"),
|
||||
));
|
||||
}
|
||||
if reads
|
||||
.iter()
|
||||
.any(|read| !matches!(read.replica, PoolMetaReplica::Valid { .. } | PoolMetaReplica::Unreadable(_)))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
// Only an ordinary, successfully read COMMIT grants authority. Rebuilding
|
||||
// two erasure shards below proves integrity, never a commit decision.
|
||||
let ordinary_commits = reads
|
||||
.iter()
|
||||
.filter_map(|read| match &read.replica {
|
||||
PoolMetaReplica::Valid {
|
||||
raw,
|
||||
canonical,
|
||||
revision,
|
||||
committed: true,
|
||||
..
|
||||
} if revision.is_generation_protocol() && raw == canonical => Some((canonical.clone(), *revision)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if ordinary_commits.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let selection = select_pool_meta_replica_reads(reads)
|
||||
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
|
||||
state
|
||||
.validate_selection(&selection)
|
||||
.map_err(|error| latch_validation(state, known_validation(error, "native_metadata_validation")))?;
|
||||
if selection.meta.pools.len() != store.pools.len()
|
||||
|| selection
|
||||
.meta
|
||||
.pools
|
||||
.iter()
|
||||
.zip(&store.pools)
|
||||
.enumerate()
|
||||
.any(|(index, (meta, pool))| meta.id != index || meta.cmd_line != pool.endpoints.cmd_line)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let canonical = selection.canonical.as_ref().ok_or_else(invalid)?;
|
||||
if !selection.revision.is_generation_protocol()
|
||||
|| !ordinary_commits
|
||||
.iter()
|
||||
.any(|(raw, revision)| raw == canonical && *revision == selection.revision)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let cluster_id = selection.revision.cluster_id.ok_or_else(invalid)?;
|
||||
if state.expected_cluster_id != Some(cluster_id) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
|
||||
if identities.iter().any(|read| {
|
||||
matches!(
|
||||
read.replica,
|
||||
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
|
||||
)
|
||||
}) {
|
||||
return Err(latch_validation(
|
||||
state,
|
||||
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
|
||||
));
|
||||
}
|
||||
if identities
|
||||
.iter()
|
||||
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let identity = select_pool_meta_identity(identities, cluster_id)
|
||||
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||
state
|
||||
.validate_identity(&identity)
|
||||
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||
let identity = identity.identity.ok_or_else(invalid)?;
|
||||
if !identity.initialized {
|
||||
return Err(invalid());
|
||||
}
|
||||
if identity.epoch != selection.revision.epoch {
|
||||
return Err(latch_validation(
|
||||
state,
|
||||
known_validation(Error::other("pool identity epoch conflicts with metadata"), "native_identity_validation"),
|
||||
));
|
||||
}
|
||||
let identity_bytes = encode_pool_meta_identity(identity)?;
|
||||
let repair_started = std::time::Instant::now();
|
||||
validate_identities(&store.pools, &identity_bytes, fence)
|
||||
.await
|
||||
.map_err(|error| latch_validation(state, error))?;
|
||||
let inspected = inspect(&store.pools, fence).await?;
|
||||
let replacements = plan(&inspected, canonical, selection.revision, &identity_bytes)
|
||||
.await
|
||||
.map_err(|error| latch_validation(state, error))?;
|
||||
fence.ensure_held()?;
|
||||
let mut arm = state.arm_transaction();
|
||||
arm.phase = Some("native_repair_apply");
|
||||
for (pool, replacements) in inspected.iter().zip(replacements) {
|
||||
for ((disk, snapshot), replacement) in pool.disks.iter().zip(&pool.snapshots).zip(replacements) {
|
||||
fence.ensure_held()?;
|
||||
let request = Request::Apply {
|
||||
object: PoolMetaNativeObject::PoolBin,
|
||||
expected_disk_id: snapshot.disk_id,
|
||||
expected_current_sha256: snapshot.current.as_ref().map(|raw| Sha256::digest(raw).into()),
|
||||
expected_backup_inventory_sha256: snapshot.inventory_sha256()?,
|
||||
replacement,
|
||||
};
|
||||
match disk.pool_meta_native_recovery(request).await? {
|
||||
Response::Applied => {}
|
||||
_ => return Err(invalid()),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Ok(pause) = AFTER_NATIVE_APPLY.try_with(Arc::clone) {
|
||||
pause.reached.notify_one();
|
||||
pause.resume.notified().await;
|
||||
}
|
||||
fence.ensure_held()?;
|
||||
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
|
||||
if confirmed.canonical.as_ref() != Some(canonical)
|
||||
|| confirmed.revision != selection.revision
|
||||
|| !confirmed.replica_state.repair_write_safe
|
||||
|| confirmed.replica_state.needs_repair
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
// Re-enter the normal V3 conditional-write path, including native CAS,
|
||||
// full tail completion, and the final ordinary quorum readback.
|
||||
for (pool, token) in store.pools.iter().cloned().zip(&confirmed.cas_tokens) {
|
||||
save_pool_meta_object_cas(pool, POOL_META_NAME, canonical.clone(), token, fence, "native_repair_confirm", &mut arm)
|
||||
.await?;
|
||||
}
|
||||
let confirmed = load_pool_meta_replicas(store.pools.clone(), true).await?;
|
||||
if confirmed.canonical.as_ref() != Some(canonical)
|
||||
|| confirmed.revision != selection.revision
|
||||
|| confirmed.replica_state.needs_repair
|
||||
|| !confirmed.replica_state.repair_write_safe
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
validate_identities(&store.pools, &identity_bytes, fence)
|
||||
.await
|
||||
.map_err(|error| latch_validation(state, error))?;
|
||||
let identities = join_all(store.pools.iter().cloned().map(read_pool_meta_identity_replica)).await;
|
||||
if identities.iter().any(|read| {
|
||||
matches!(
|
||||
read.replica,
|
||||
PoolMetaIdentityReplica::Corrupt(_) | PoolMetaIdentityReplica::Incompatible(_)
|
||||
)
|
||||
}) {
|
||||
return Err(latch_validation(
|
||||
state,
|
||||
known_validation(Error::other("pool identity is corrupt or incompatible"), "native_identity_validation"),
|
||||
));
|
||||
}
|
||||
if identities
|
||||
.iter()
|
||||
.any(|read| !matches!(read.replica, PoolMetaIdentityReplica::Valid(_)))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let identity = select_pool_meta_identity(identities, cluster_id)
|
||||
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||
state
|
||||
.validate_identity(&identity)
|
||||
.map_err(|error| latch_validation(state, known_validation(error, "native_identity_validation")))?;
|
||||
if identity.identity.map(encode_pool_meta_identity).transpose()?.as_deref() != Some(identity_bytes.as_slice()) {
|
||||
return Err(latch_validation(
|
||||
state,
|
||||
known_validation(Error::other("native identity changed after repair"), "native_identity_validation"),
|
||||
));
|
||||
}
|
||||
fence.ensure_held()?;
|
||||
arm.disarm();
|
||||
info!(
|
||||
event = EVENT_DECOMMISSION_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_POOLS,
|
||||
state = "pool_metadata_native_repaired",
|
||||
generation = selection.revision.generation,
|
||||
pools = inspected.len(),
|
||||
disks = inspected.iter().map(|pool| pool.disks.len()).sum::<usize>(),
|
||||
elapsed_ms = repair_started.elapsed().as_secs_f64() * 1000.0,
|
||||
"Pool metadata native replicas repaired"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codec::{fixture_inline_pool_meta_replicas as replicas, fixture_split_inline_pool_meta_replicas as split};
|
||||
|
||||
struct Fixture {
|
||||
pools: Vec<InspectedPool>,
|
||||
commit: Vec<u8>,
|
||||
pending: Vec<u8>,
|
||||
identity: Vec<u8>,
|
||||
revision: PoolMetaRevision,
|
||||
modified: OffsetDateTime,
|
||||
}
|
||||
|
||||
async fn fixture() -> Fixture {
|
||||
let cluster_id = uuid::Uuid::new_v4();
|
||||
let revision = PoolMetaRevision {
|
||||
version: POOL_META_GENERATION_VERSION,
|
||||
cluster_id: Some(cluster_id),
|
||||
epoch: 1,
|
||||
generation: 1,
|
||||
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||
};
|
||||
let meta = PoolMeta {
|
||||
version: POOL_META_GENERATION_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let previous_meta = PoolMeta {
|
||||
version: POOL_META_V1_VERSION,
|
||||
..Default::default()
|
||||
};
|
||||
let previous = PoolMetaCommittedCandidate {
|
||||
canonical: previous_meta.encode_config_data_for_v2_gate(true).unwrap(),
|
||||
meta: previous_meta,
|
||||
revision: PoolMetaRevision::legacy(POOL_META_V1_VERSION),
|
||||
};
|
||||
let pending = encode_pool_meta_v3_envelope(&meta, revision, false, Some(&previous)).unwrap();
|
||||
let commit = encode_pool_meta_v3_envelope(&meta, revision, true, None).unwrap();
|
||||
let identity = encode_pool_meta_identity(PersistedPoolMetaIdentity {
|
||||
version: POOL_META_IDENTITY_VERSION,
|
||||
cluster_id,
|
||||
epoch: 1,
|
||||
initialized: true,
|
||||
fresh_bootstrap_nonce: None,
|
||||
})
|
||||
.unwrap();
|
||||
let modified = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
|
||||
let current_split = split(&pending, &commit, modified).await;
|
||||
let current_commit = replicas(&commit, modified + Duration::seconds(1)).await;
|
||||
let backups = replicas(&pending, modified).await;
|
||||
let identities = replicas(&identity, modified).await;
|
||||
let directory = uuid::Uuid::new_v4();
|
||||
let pools = [current_split.clone(), current_split, current_commit.clone(), current_commit]
|
||||
.into_iter()
|
||||
.map(|current| InspectedPool {
|
||||
disks: Vec::new(),
|
||||
snapshots: current
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, current)| PoolMetaNativeInspection {
|
||||
disk_id: uuid::Uuid::new_v4(),
|
||||
identity: Some(identities[index].clone()),
|
||||
current: Some(current),
|
||||
backups: vec![crate::disk::PoolMetaNativeBackup {
|
||||
directory,
|
||||
data: backups[index].clone(),
|
||||
}],
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
Fixture {
|
||||
pools,
|
||||
commit,
|
||||
pending,
|
||||
identity,
|
||||
revision,
|
||||
modified,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_plan_restores_physical_split_with_fresh_common_identity() {
|
||||
let f = fixture().await;
|
||||
let repaired = plan(&f.pools, &f.commit, f.revision, &f.identity).await.unwrap();
|
||||
assert_eq!(repaired.len(), 4);
|
||||
for pool in repaired {
|
||||
assert_eq!(
|
||||
codec::decode_inline_pool_meta_replicas(&pool).await.unwrap(),
|
||||
vec![Bytes::copy_from_slice(&f.commit)]
|
||||
);
|
||||
let times = pool
|
||||
.iter()
|
||||
.map(|raw| codec::parse_inline_info(raw).unwrap().mod_time.unwrap())
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(times.len(), 1);
|
||||
assert!(*times.iter().next().unwrap() > f.modified + Duration::seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_plan_rejects_single_hidden_identity_fork() {
|
||||
let mut f = fixture().await;
|
||||
let wrong = replicas(b"different identity", f.modified).await;
|
||||
f.pools[0].snapshots[0].identity = Some(wrong[0].clone());
|
||||
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_plan_rejects_hidden_higher_generation_and_unknown_backup() {
|
||||
let mut f = fixture().await;
|
||||
let higher_revision = PoolMetaRevision {
|
||||
generation: 2,
|
||||
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||
..f.revision
|
||||
};
|
||||
let higher = encode_pool_meta_v3_envelope(
|
||||
&PoolMeta {
|
||||
version: POOL_META_GENERATION_VERSION,
|
||||
..Default::default()
|
||||
},
|
||||
higher_revision,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let wrong = replicas(&higher, f.modified + Duration::seconds(2)).await;
|
||||
f.pools[0].snapshots[0].current = Some(wrong[0].clone());
|
||||
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||
f.pools[0].snapshots[0].current = Some(replicas(&f.pending, f.modified).await[0].clone());
|
||||
f.pools[0].snapshots[0].backups[0].data = wrong[0].clone();
|
||||
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_plan_accepts_retained_older_recovery_backups() {
|
||||
let mut f = fixture().await;
|
||||
let older_commit = f.commit.clone();
|
||||
let older_pending = f.pending.clone();
|
||||
let next = PoolMetaRevision {
|
||||
generation: 2,
|
||||
transaction_id: Some(uuid::Uuid::new_v4()),
|
||||
..f.revision
|
||||
};
|
||||
let PoolMetaReplica::Valid { meta, .. } = decode_pool_meta_replica(older_commit.clone()) else {
|
||||
panic!("valid fixture")
|
||||
};
|
||||
let previous = PoolMetaCommittedCandidate {
|
||||
canonical: older_commit.clone(),
|
||||
meta: meta.clone(),
|
||||
revision: f.revision,
|
||||
};
|
||||
f.commit = encode_pool_meta_v3_envelope(&meta, next, true, None).unwrap();
|
||||
f.pending = encode_pool_meta_v3_envelope(&meta, next, false, Some(&previous)).unwrap();
|
||||
f.revision = next;
|
||||
let current = split(&f.pending, &f.commit, f.modified + Duration::seconds(2)).await;
|
||||
let old_commit = replicas(&older_commit, f.modified + Duration::seconds(1)).await;
|
||||
let current_pending = replicas(&f.pending, f.modified + Duration::seconds(2)).await;
|
||||
let new_directory = uuid::Uuid::new_v4();
|
||||
let old_directory = uuid::Uuid::new_v4();
|
||||
for pool in &mut f.pools {
|
||||
for (index, snapshot) in pool.snapshots.iter_mut().enumerate() {
|
||||
snapshot.current = Some(current[index].clone());
|
||||
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
|
||||
directory: new_directory,
|
||||
data: current_pending[index].clone(),
|
||||
});
|
||||
snapshot.backups.push(crate::disk::PoolMetaNativeBackup {
|
||||
directory: old_directory,
|
||||
data: old_commit[index].clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
assert!(older_pending != f.pending);
|
||||
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_native_plan_rejects_missing_or_corrupt_evidence() {
|
||||
let mut f = fixture().await;
|
||||
let before = f.pools[1].snapshots[2].current.take();
|
||||
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||
f.pools[1].snapshots[2].current = before;
|
||||
f.pools[1].snapshots[2].backups[0].data = Bytes::from_static(b"invalid metadata");
|
||||
assert!(plan(&f.pools, &f.commit, f.revision, &f.identity).await.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "test-util"))]
|
||||
#[path = "pool_meta_native_integration_tests.rs"]
|
||||
mod integration_tests;
|
||||
@@ -0,0 +1,569 @@
|
||||
// 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.
|
||||
|
||||
//! Integrity-only codec for conditional recovery of an inline `pool.bin` head.
|
||||
//! Reconstructing a payload here never grants authority to publish it.
|
||||
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::erasure::coding::encode::IntegrityEncodeMode;
|
||||
use crate::erasure::coding::{BitrotReader, BitrotWriterWrapper, Erasure, calc_shard_size};
|
||||
use crate::io_support::shard_integrity::{PartProofReader, ShardVerifier};
|
||||
use bytes::Bytes;
|
||||
use rustfs_filemeta::{
|
||||
BLOCK_SIZE_V2, ChecksumAlgo, ChecksumInfo, ERASURE_ALGORITHM, FileInfo, FileInfoOpts, FileMeta, get_file_info,
|
||||
};
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use rustfs_utils::http::{SUFFIX_ACTUAL_SIZE, SUFFIX_INLINE_DATA, get_consistent_str, insert_str, remove_str};
|
||||
use std::io::{self, Cursor};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
const OBJECT: &str = "pool.bin";
|
||||
const MAX_SHARDS: usize = 16;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(super) enum PoolMetaCodecError {
|
||||
#[error("unsupported inline pool metadata layout")]
|
||||
UnsupportedLayout,
|
||||
#[error("pool metadata shard does not match its physical disk")]
|
||||
ShardPlacement,
|
||||
#[error("pool metadata replicas disagree on persistent attributes")]
|
||||
InconsistentMetadata,
|
||||
#[error("pool metadata payload does not match its metadata or shards")]
|
||||
PayloadMismatch,
|
||||
#[error("invalid inline pool metadata")]
|
||||
Metadata(#[source] rustfs_filemeta::Error),
|
||||
#[error("inline pool metadata integrity verification failed")]
|
||||
Integrity(#[source] io::Error),
|
||||
}
|
||||
|
||||
impl From<rustfs_filemeta::Error> for PoolMetaCodecError {
|
||||
fn from(value: rustfs_filemeta::Error) -> Self {
|
||||
Self::Metadata(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for PoolMetaCodecError {
|
||||
fn from(value: io::Error) -> Self {
|
||||
Self::Integrity(value)
|
||||
}
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, PoolMetaCodecError>;
|
||||
|
||||
/// Rejects layouts outside the narrow, current inline pool metadata writer.
|
||||
pub(super) fn parse_inline_info(raw: &Bytes) -> Result<FileInfo> {
|
||||
if FileMeta::read_format_versions(raw)? != (1, 3, 3, 3) {
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
let meta = FileMeta::load(raw)?;
|
||||
if meta.versions.len() != 1 {
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
if meta.versions[0]
|
||||
.parse_version_meta()?
|
||||
.object
|
||||
.is_none_or(|object| object.bitrot_checksum_algo != ChecksumAlgo::HighwayHash)
|
||||
{
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
let info = get_file_info(
|
||||
raw,
|
||||
RUSTFS_META_BUCKET,
|
||||
OBJECT,
|
||||
"",
|
||||
FileInfoOpts {
|
||||
data: true,
|
||||
include_free_versions: true,
|
||||
include_part_checksums: true,
|
||||
},
|
||||
)?;
|
||||
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
|
||||
let erasure = &info.erasure;
|
||||
let shards = erasure.data_blocks.checked_add(erasure.parity_blocks);
|
||||
if !info.is_valid()
|
||||
|| info.deleted
|
||||
|| info.mark_deleted
|
||||
|| info.version_id.is_some_and(|id| !id.is_nil())
|
||||
|| info.versioned
|
||||
|| info.uses_legacy_checksum
|
||||
|| info.data_dir.is_none_or(|id| id.is_nil())
|
||||
|| info.mod_time.is_none_or(|modified| modified <= OffsetDateTime::UNIX_EPOCH)
|
||||
|| info.checksum.is_some()
|
||||
|| erasure.algorithm != ERASURE_ALGORITHM
|
||||
|| erasure.block_size != BLOCK_SIZE_V2
|
||||
|| erasure.data_blocks == 0
|
||||
|| erasure.data_blocks != erasure.parity_blocks
|
||||
|| shards.is_none_or(|count| !(2..=MAX_SHARDS).contains(&count))
|
||||
|| size == 0
|
||||
|| size > BLOCK_SIZE_V2
|
||||
|| info.parts.len() != 1
|
||||
|| info.data.is_none()
|
||||
|| get_consistent_str(&info.metadata, SUFFIX_INLINE_DATA) != Some("true")
|
||||
{
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
let part = &info.parts[0];
|
||||
let etag = info.metadata.get("etag").ok_or(PoolMetaCodecError::UnsupportedLayout)?;
|
||||
if part.number != 1
|
||||
|| part.size != size
|
||||
|| part.actual_size != info.size
|
||||
|| part.etag != *etag
|
||||
|| part.index.is_some()
|
||||
|| part.checksums.is_some()
|
||||
|| part.error.is_some()
|
||||
|| etag.len() != 32
|
||||
|| !etag
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
|| get_consistent_str(&info.metadata, SUFFIX_ACTUAL_SIZE).and_then(|actual| actual.parse::<usize>().ok()) != Some(size)
|
||||
|| !erasure.checksums.is_empty()
|
||||
{
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
fn validate_placement(info: &FileInfo, physical_index: usize, disk_count: usize) -> Result<()> {
|
||||
if info.erasure.distribution.len() != disk_count
|
||||
|| info.erasure.data_blocks + info.erasure.parity_blocks != disk_count
|
||||
|| info.erasure.distribution.get(physical_index).copied() != Some(info.erasure.index)
|
||||
{
|
||||
return Err(PoolMetaCodecError::ShardPlacement);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn erasure(info: &FileInfo) -> Result<Erasure> {
|
||||
Erasure::try_new(info.erasure.data_blocks, info.erasure.parity_blocks, info.erasure.block_size)
|
||||
.map_err(|_| PoolMetaCodecError::UnsupportedLayout)
|
||||
}
|
||||
|
||||
fn md5(payload: &[u8]) -> String {
|
||||
hex_simd::encode_to_string(HashAlgorithm::Md5.hash_encode(payload).as_ref(), hex_simd::AsciiCase::Lower)
|
||||
}
|
||||
|
||||
async fn verified_shard(info: &FileInfo) -> Result<Vec<u8>> {
|
||||
let size = usize::try_from(info.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
|
||||
let shard_size = calc_shard_size(size, info.erasure.data_blocks);
|
||||
let data = info.data.as_ref().ok_or(PoolMetaCodecError::UnsupportedLayout)?;
|
||||
if data.len() != shard_size + HashAlgorithm::HighwayHash256S.size() {
|
||||
return Err(PoolMetaCodecError::PayloadMismatch);
|
||||
}
|
||||
let mut reader = BitrotReader::new(Cursor::new(data.clone()), shard_size, HashAlgorithm::HighwayHash256S, false);
|
||||
if let Some(integrity) = &info.parts[0].integrity {
|
||||
let proof = PartProofReader::new(integrity.clone(), std::slice::from_ref(info), &[None], RUSTFS_META_BUCKET, OBJECT)?;
|
||||
reader.set_integrity(ShardVerifier::new(proof, info.erasure.index - 1, 0, None)?)?;
|
||||
}
|
||||
let mut shard = vec![0; shard_size];
|
||||
if reader.read(&mut shard).await? != shard_size {
|
||||
return Err(PoolMetaCodecError::PayloadMismatch);
|
||||
}
|
||||
Ok(shard)
|
||||
}
|
||||
|
||||
fn generation_key(info: &FileInfo) -> FileInfo {
|
||||
let mut key = info.clone();
|
||||
key.erasure.index = 0;
|
||||
key.data = None;
|
||||
key
|
||||
}
|
||||
|
||||
/// Decodes every reconstructable generation without making a quorum decision.
|
||||
///
|
||||
/// All physical disks must be present and every supplied shard must verify.
|
||||
/// Groups below the erasure reconstruction threshold are left undecoded; the
|
||||
/// caller must match every such shard against an independently known payload
|
||||
/// with [`validate_inline_pool_meta_shard`] before proposing any mutation.
|
||||
pub(super) async fn decode_inline_pool_meta_replicas(raw: &[Bytes]) -> Result<Vec<Bytes>> {
|
||||
if !(2..=MAX_SHARDS).contains(&raw.len()) {
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
struct Group {
|
||||
key: FileInfo,
|
||||
present: usize,
|
||||
shards: Vec<Option<Vec<u8>>>,
|
||||
}
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
for (physical_index, bytes) in raw.iter().enumerate() {
|
||||
let info = parse_inline_info(bytes)?;
|
||||
validate_placement(&info, physical_index, raw.len())?;
|
||||
let shard = verified_shard(&info).await?;
|
||||
let key = generation_key(&info);
|
||||
let group_index = if let Some(index) = groups.iter().position(|group| group.key == key) {
|
||||
index
|
||||
} else {
|
||||
groups.push(Group {
|
||||
key,
|
||||
present: 0,
|
||||
shards: vec![None; raw.len()],
|
||||
});
|
||||
groups.len() - 1
|
||||
};
|
||||
let group = &mut groups[group_index];
|
||||
let slot = &mut group.shards[info.erasure.index - 1];
|
||||
if slot.is_some() {
|
||||
return Err(PoolMetaCodecError::ShardPlacement);
|
||||
}
|
||||
*slot = Some(shard);
|
||||
group.present += 1;
|
||||
}
|
||||
let mut decoded = Vec::with_capacity(groups.len());
|
||||
for mut group in groups {
|
||||
if group.present < group.key.erasure.data_blocks {
|
||||
continue;
|
||||
}
|
||||
let codec = erasure(&group.key)?;
|
||||
codec.decode_data(&mut group.shards)?;
|
||||
let size = usize::try_from(group.key.size).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
|
||||
let mut payload = Vec::with_capacity(size);
|
||||
for shard in group.shards.iter().take(group.key.erasure.data_blocks) {
|
||||
let shard = shard.as_ref().ok_or(PoolMetaCodecError::PayloadMismatch)?;
|
||||
let remaining = size.saturating_sub(payload.len());
|
||||
payload.extend_from_slice(&shard[..remaining.min(shard.len())]);
|
||||
}
|
||||
if payload.len() != size || group.key.metadata.get("etag") != Some(&md5(&payload)) {
|
||||
return Err(PoolMetaCodecError::PayloadMismatch);
|
||||
}
|
||||
let encoded = codec.encode_data(&payload)?;
|
||||
if group
|
||||
.shards
|
||||
.iter()
|
||||
.zip(&encoded)
|
||||
.any(|(actual, expected)| actual.as_ref().is_some_and(|actual| actual.as_slice() != expected.as_ref()))
|
||||
{
|
||||
return Err(PoolMetaCodecError::PayloadMismatch);
|
||||
}
|
||||
decoded.push(Bytes::from(payload));
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
/// Verifies a minority shard against a payload whose authority was established elsewhere.
|
||||
pub(super) async fn validate_inline_pool_meta_shard(raw: &Bytes, physical_index: usize, payload: &[u8]) -> Result<()> {
|
||||
let info = parse_inline_info(raw)?;
|
||||
validate_placement(&info, physical_index, info.erasure.distribution.len())?;
|
||||
if usize::try_from(info.size).ok() != Some(payload.len()) || info.metadata.get("etag") != Some(&md5(payload)) {
|
||||
return Err(PoolMetaCodecError::PayloadMismatch);
|
||||
}
|
||||
let actual = verified_shard(&info).await?;
|
||||
let encoded = erasure(&info)?.encode_data(payload)?;
|
||||
if encoded
|
||||
.get(info.erasure.index - 1)
|
||||
.is_none_or(|expected| expected.as_ref() != actual.as_slice())
|
||||
{
|
||||
return Err(PoolMetaCodecError::PayloadMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persistent_attributes(info: &FileInfo) -> FileInfo {
|
||||
let mut attributes = generation_key(info);
|
||||
attributes.data_dir = None;
|
||||
attributes.mod_time = None;
|
||||
attributes.size = 0;
|
||||
attributes.parts.clear();
|
||||
attributes.erasure.checksums.clear();
|
||||
attributes.metadata.remove("etag");
|
||||
remove_str(&mut attributes.metadata, SUFFIX_ACTUAL_SIZE);
|
||||
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut attributes.metadata);
|
||||
attributes
|
||||
}
|
||||
|
||||
/// Builds disk-specific inline heads while retaining each disk's existing FileMeta.
|
||||
/// The caller supplies the independently authorized canonical COMMIT payload.
|
||||
pub(super) async fn encode_inline_pool_meta_replicas(
|
||||
raw: &[Bytes],
|
||||
canonical: &[u8],
|
||||
modified: OffsetDateTime,
|
||||
) -> Result<Vec<Bytes>> {
|
||||
if !(2..=MAX_SHARDS).contains(&raw.len()) || canonical.is_empty() || canonical.len() > BLOCK_SIZE_V2 {
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
let mut infos = Vec::with_capacity(raw.len());
|
||||
for (physical_index, bytes) in raw.iter().enumerate() {
|
||||
let info = parse_inline_info(bytes)?;
|
||||
validate_placement(&info, physical_index, raw.len())?;
|
||||
verified_shard(&info).await?;
|
||||
if info.mod_time.is_none_or(|previous| modified <= previous) {
|
||||
return Err(PoolMetaCodecError::UnsupportedLayout);
|
||||
}
|
||||
if let Some(first) = infos.first()
|
||||
&& persistent_attributes(first) != persistent_attributes(&info)
|
||||
{
|
||||
return Err(PoolMetaCodecError::InconsistentMetadata);
|
||||
}
|
||||
infos.push(info);
|
||||
}
|
||||
let mut base = infos.first().ok_or(PoolMetaCodecError::UnsupportedLayout)?.clone();
|
||||
let protected = infos.iter().any(|info| info.parts[0].integrity.is_some());
|
||||
let codec = Arc::new(erasure(&base)?);
|
||||
let mut writers: Vec<Option<BitrotWriterWrapper>> = std::iter::repeat_with(|| None).take(raw.len()).collect();
|
||||
let (_, written, shards, integrity) = Arc::clone(&codec)
|
||||
.encode_with_shard_integrity(
|
||||
Cursor::new(Bytes::copy_from_slice(canonical)),
|
||||
&mut writers,
|
||||
raw.len(),
|
||||
1,
|
||||
IntegrityEncodeMode::Inline(canonical.len()),
|
||||
protected,
|
||||
)
|
||||
.await?;
|
||||
if written != canonical.len() {
|
||||
return Err(PoolMetaCodecError::PayloadMismatch);
|
||||
}
|
||||
let shards = shards.ok_or(PoolMetaCodecError::PayloadMismatch)?;
|
||||
let etag = md5(canonical);
|
||||
base.data_dir = Some(Uuid::new_v4());
|
||||
base.mod_time = Some(modified);
|
||||
base.size = i64::try_from(canonical.len()).map_err(|_| PoolMetaCodecError::UnsupportedLayout)?;
|
||||
base.metadata.insert("etag".to_owned(), etag.clone());
|
||||
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, canonical.len().to_string());
|
||||
base.parts.clear();
|
||||
base.add_object_part(1, etag, canonical.len(), Some(modified), base.size, None, None);
|
||||
rustfs_filemeta::shard_integrity::clear_integrity_metadata(&mut base.metadata);
|
||||
if let Some(integrity) = integrity {
|
||||
integrity.set_inline_metadata(&mut base)?;
|
||||
base.parts[0].integrity = Some(integrity.part);
|
||||
}
|
||||
base.persist_shard_integrity()?;
|
||||
base.erasure.checksums = vec![ChecksumInfo {
|
||||
part_number: 1,
|
||||
algorithm: HashAlgorithm::HighwayHash256S,
|
||||
hash: Bytes::new(),
|
||||
}];
|
||||
base.set_inline_data();
|
||||
let mut replacement = Vec::with_capacity(raw.len());
|
||||
for (bytes, original) in raw.iter().zip(infos) {
|
||||
let mut info = base.clone();
|
||||
info.erasure.index = original.erasure.index;
|
||||
info.data = Some(
|
||||
shards
|
||||
.get(info.erasure.index - 1)
|
||||
.ok_or(PoolMetaCodecError::ShardPlacement)?
|
||||
.clone(),
|
||||
);
|
||||
let mut meta = FileMeta::load(bytes)?;
|
||||
meta.add_version(info)?;
|
||||
replacement.push(Bytes::from(meta.marshal_msg()?));
|
||||
}
|
||||
Ok(replacement)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn fixture_inline_pool_meta_replicas(payload: &[u8], modified: OffsetDateTime) -> Vec<Bytes> {
|
||||
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
|
||||
let mut base = FileInfo::new(".rustfs.sys/pool.bin", 2, 2);
|
||||
base.volume = RUSTFS_META_BUCKET.to_owned();
|
||||
base.name = OBJECT.to_owned();
|
||||
base.data_dir = Some(Uuid::new_v4());
|
||||
base.mod_time = Some(modified);
|
||||
base.size = i64::try_from(payload.len()).expect("test payload should fit i64");
|
||||
let etag = md5(payload);
|
||||
base.metadata.insert("etag".to_owned(), etag.clone());
|
||||
base.metadata
|
||||
.insert("content-type".to_owned(), "application/octet-stream".to_owned());
|
||||
insert_str(&mut base.metadata, SUFFIX_ACTUAL_SIZE, payload.len().to_string());
|
||||
base.set_inline_data();
|
||||
base.add_object_part(1, etag, payload.len(), Some(modified), base.size, None, None);
|
||||
let codec = erasure(&base).expect("fixture layout should construct");
|
||||
let shards = codec.encode_data(payload).expect("fixture erasure encoding should succeed");
|
||||
let mut raw = Vec::with_capacity(4);
|
||||
for coding_index in &base.erasure.distribution {
|
||||
let mut writer =
|
||||
BitrotWriterWrapper::new(CustomWriter::new_inline_buffer(), codec.shard_size(), HashAlgorithm::HighwayHash256S);
|
||||
writer
|
||||
.write(&shards[*coding_index - 1])
|
||||
.await
|
||||
.expect("fixture bitrot writer should write");
|
||||
writer.shutdown().await.expect("fixture bitrot writer should finish");
|
||||
let mut info = base.clone();
|
||||
info.erasure.index = *coding_index;
|
||||
info.data = Some(Bytes::from(writer.into_inline_data().expect("fixture writer should retain inline bytes")));
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(info).expect("test inline FileInfo should serialize");
|
||||
raw.push(Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize")));
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn fixture_split_inline_pool_meta_replicas(
|
||||
prepare: &[u8],
|
||||
commit: &[u8],
|
||||
modified: OffsetDateTime,
|
||||
) -> Vec<Bytes> {
|
||||
let mut raw = fixture_inline_pool_meta_replicas(prepare, modified).await;
|
||||
let committed = fixture_inline_pool_meta_replicas(commit, modified + time::Duration::seconds(1)).await;
|
||||
raw[2..].clone_from_slice(&committed[2..]);
|
||||
raw
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fixture_inline_pool_meta_replicas as fixtures;
|
||||
|
||||
fn time(seconds: i64) -> OffsetDateTime {
|
||||
OffsetDateTime::from_unix_timestamp(seconds).expect("test timestamp should be valid")
|
||||
}
|
||||
|
||||
fn metadata(info: FileInfo) -> Bytes {
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(info).expect("test inline FileInfo should serialize");
|
||||
Bytes::from(meta.marshal_msg().expect("test xl.meta should serialize"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_reconstructs_both_halves_without_treating_them_as_authority() {
|
||||
let raw = fixture_split_inline_pool_meta_replicas(b"prepare with predecessor", b"commit", time(10)).await;
|
||||
let decoded = decode_inline_pool_meta_replicas(&raw)
|
||||
.await
|
||||
.expect("two shards should reconstruct each payload");
|
||||
assert_eq!(decoded.len(), 2);
|
||||
assert_eq!(decoded[0].as_ref(), b"prepare with predecessor");
|
||||
assert_eq!(decoded[1].as_ref(), b"commit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_requires_known_payload_for_single_minority_shard() {
|
||||
let previous = fixtures(b"previous", time(10)).await;
|
||||
let commit = fixtures(b"commit", time(11)).await;
|
||||
let raw = vec![previous[0].clone(), commit[1].clone(), commit[2].clone(), commit[3].clone()];
|
||||
let decoded = decode_inline_pool_meta_replicas(&raw)
|
||||
.await
|
||||
.expect("three commit shards should reconstruct");
|
||||
assert_eq!(decoded.len(), 1);
|
||||
assert_eq!(decoded[0].as_ref(), b"commit");
|
||||
validate_inline_pool_meta_shard(&raw[0], 0, b"previous")
|
||||
.await
|
||||
.expect("known predecessor should verify");
|
||||
assert!(matches!(
|
||||
validate_inline_pool_meta_shard(&raw[0], 0, b"commit").await,
|
||||
Err(PoolMetaCodecError::PayloadMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_rejects_bitrot_even_with_other_reconstructable_shards() {
|
||||
let mut raw = fixtures(b"commit", time(10)).await;
|
||||
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
|
||||
let mut data = info.data.take().expect("fixture should be inline").to_vec();
|
||||
data[HashAlgorithm::HighwayHash256S.size()] ^= 1;
|
||||
info.data = Some(Bytes::from(data));
|
||||
raw[0] = metadata(info);
|
||||
assert!(matches!(
|
||||
decode_inline_pool_meta_replicas(&raw).await,
|
||||
Err(PoolMetaCodecError::Integrity(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_rejects_trailing_inline_bytes() {
|
||||
let mut raw = fixtures(b"commit", time(10)).await;
|
||||
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
|
||||
let mut data = info.data.take().expect("fixture should be inline").to_vec();
|
||||
data.push(0);
|
||||
info.data = Some(Bytes::from(data));
|
||||
raw[0] = metadata(info);
|
||||
assert!(matches!(
|
||||
decode_inline_pool_meta_replicas(&raw).await,
|
||||
Err(PoolMetaCodecError::PayloadMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_rejects_duplicate_physical_shard_mapping() {
|
||||
let mut raw = fixtures(b"commit", time(10)).await;
|
||||
raw.swap(0, 1);
|
||||
assert!(matches!(
|
||||
decode_inline_pool_meta_replicas(&raw).await,
|
||||
Err(PoolMetaCodecError::ShardPlacement)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_rejects_payload_md5_disagreement_after_valid_bitrot() {
|
||||
let mut raw = fixtures(b"commit", time(10)).await;
|
||||
for bytes in &mut raw {
|
||||
let mut info = parse_inline_info(bytes).expect("fixture should parse");
|
||||
info.metadata
|
||||
.insert("etag".to_owned(), "00000000000000000000000000000000".to_owned());
|
||||
info.parts[0].etag = "00000000000000000000000000000000".to_owned();
|
||||
*bytes = metadata(info);
|
||||
}
|
||||
assert!(matches!(
|
||||
decode_inline_pool_meta_replicas(&raw).await,
|
||||
Err(PoolMetaCodecError::PayloadMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_reencodes_split_heads_to_one_canonical_generation() {
|
||||
let prepare = fixtures(b"prepare with predecessor", time(10)).await;
|
||||
let commit = fixtures(b"commit", time(11)).await;
|
||||
let raw = vec![prepare[0].clone(), prepare[1].clone(), commit[2].clone(), commit[3].clone()];
|
||||
let repaired = encode_inline_pool_meta_replicas(&raw, b"commit", time(12))
|
||||
.await
|
||||
.expect("split heads should encode");
|
||||
let decoded = decode_inline_pool_meta_replicas(&repaired)
|
||||
.await
|
||||
.expect("repaired set should decode");
|
||||
assert_eq!(decoded.len(), 1);
|
||||
assert_eq!(decoded[0].as_ref(), b"commit");
|
||||
let info = parse_inline_info(&repaired[0]).expect("repaired metadata should parse");
|
||||
assert_eq!(info.mod_time, Some(time(12)));
|
||||
assert_eq!(info.metadata.get("content-type").map(String::as_str), Some("application/octet-stream"));
|
||||
for (index, bytes) in repaired.iter().enumerate() {
|
||||
validate_inline_pool_meta_shard(bytes, index, b"commit")
|
||||
.await
|
||||
.expect("every repaired disk should match commit");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_preserves_required_persistent_metadata_agreement() {
|
||||
let mut raw = fixtures(b"commit", time(10)).await;
|
||||
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
|
||||
info.metadata.insert("content-type".to_owned(), "text/plain".to_owned());
|
||||
raw[0] = metadata(info);
|
||||
assert!(matches!(
|
||||
encode_inline_pool_meta_replicas(&raw, b"commit", time(11)).await,
|
||||
Err(PoolMetaCodecError::InconsistentMetadata)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_requires_strictly_newer_common_modification_time() {
|
||||
let raw = fixtures(b"commit", time(10)).await;
|
||||
assert!(matches!(
|
||||
encode_inline_pool_meta_replicas(&raw, b"commit", time(10)).await,
|
||||
Err(PoolMetaCodecError::UnsupportedLayout)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_meta_codec_rejects_noninline_heads_and_wrong_disk_count() {
|
||||
let mut raw = fixtures(b"commit", time(10)).await;
|
||||
assert!(matches!(
|
||||
decode_inline_pool_meta_replicas(&raw[..3]).await,
|
||||
Err(PoolMetaCodecError::ShardPlacement)
|
||||
));
|
||||
let mut info = parse_inline_info(&raw[0]).expect("fixture should parse");
|
||||
info.data = None;
|
||||
raw[0] = metadata(info);
|
||||
assert!(matches!(parse_inline_info(&raw[0]), Err(PoolMetaCodecError::UnsupportedLayout)));
|
||||
}
|
||||
}
|
||||
+995
-141
File diff suppressed because it is too large
Load Diff
@@ -473,7 +473,6 @@ mod decommission_lock_order_tests {
|
||||
let ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
||||
ctx.update_erasure_type(crate::layout::endpoints::SetupType::DistErasure)
|
||||
.await;
|
||||
*ctx.local_disk_map().write().await = other_store.ctx.local_disk_map().read().await.clone();
|
||||
let endpoints = other_store
|
||||
.ctx
|
||||
.endpoints()
|
||||
@@ -482,9 +481,43 @@ mod decommission_lock_order_tests {
|
||||
if let Some(deployment_id) = other_store.ctx.deployment_id() {
|
||||
ctx.set_deployment_id(deployment_id);
|
||||
}
|
||||
let authority = ctx
|
||||
.bind_multipart_proof_topology(&endpoints)
|
||||
.expect("lease-loss fixture must own its complete native proof topology");
|
||||
let mut local_disk_map = HashMap::new();
|
||||
for pool in &mut pools {
|
||||
Arc::make_mut(pool).set_instance_ctx_for_test(Arc::clone(&ctx));
|
||||
let pool = Arc::make_mut(pool);
|
||||
pool.set_instance_ctx_for_test(Arc::clone(&ctx));
|
||||
for set in &mut pool.disk_set {
|
||||
let set = Arc::make_mut(set);
|
||||
let source_disks = set.disks.read().await.clone();
|
||||
let mut disks = Vec::with_capacity(set.set_endpoints.len());
|
||||
for (slot, endpoint) in set.set_endpoints.iter().enumerate() {
|
||||
// The logical node clone shares durable contents, never a
|
||||
// native handle already owned by another instance scope.
|
||||
let disk = crate::disk::new_disk(
|
||||
endpoint,
|
||||
&crate::disk::DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("open the lease-loss node's independently owned native handle");
|
||||
assert!(!Arc::ptr_eq(&disk, source_disks[slot].as_ref().expect("source fixture disk")));
|
||||
assert_eq!(
|
||||
crate::disk::DiskAPI::get_disk_id(disk.as_ref()).await.unwrap(),
|
||||
Some(pool.format.erasure.sets[set.set_index][slot])
|
||||
);
|
||||
local_disk_map.insert(endpoint.to_string(), Some(disk.clone()));
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
crate::disk::multipart_source::bind_multipart_proof_snapshot(&disks, &authority)
|
||||
.expect("lease-loss native handles must bind only to this fixture instance");
|
||||
set.disks = Arc::new(tokio::sync::RwLock::new(disks));
|
||||
}
|
||||
}
|
||||
*ctx.local_disk_map().write().await = local_disk_map;
|
||||
let store = Arc::new(crate::store::ECStore {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
disk_map: other_store.disk_map.clone(),
|
||||
@@ -6886,7 +6919,6 @@ mod decommission_lock_order_tests {
|
||||
};
|
||||
owner.apply_to(&mut abort_opts);
|
||||
let barrier = MultipartCommitBarrier::install(&bucket, object, MultipartCommitPause::AbortAfterDelete);
|
||||
tokio::time::pause();
|
||||
let abort = tokio::spawn({
|
||||
let abort_store = Arc::clone(&lossy_store);
|
||||
let abort_bucket = bucket.clone();
|
||||
@@ -6906,6 +6938,9 @@ mod decommission_lock_order_tests {
|
||||
}
|
||||
});
|
||||
barrier.wait_until_paused().await;
|
||||
// Native cleanup performs real filesystem I/O. Pause time only after
|
||||
// durable deletion, when the abort is blocked before its release save.
|
||||
tokio::time::pause();
|
||||
tokio::task::yield_now().await;
|
||||
refresh_calls.arm();
|
||||
tokio::time::advance(Duration::from_secs(11)).await;
|
||||
|
||||
+11
-204
@@ -19,7 +19,6 @@ use crate::layout::set_heal::{formats_to_drives_info, new_heal_format_sets};
|
||||
use crate::multipart_listing::paginate_multipart_listing;
|
||||
use crate::storage_api_contracts::{
|
||||
bucket::{BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, MakeBucketOptions},
|
||||
list::{StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions},
|
||||
multipart::{CompletePart, ListMultipartsInfo, ListPartsInfo, MultipartInfo, MultipartUploadResult, PartInfo},
|
||||
object::{DeleteAccounting, DeletedObject, ObjectIO as _, ObjectOperations as _, ObjectToDelete},
|
||||
range::HTTPRangeSpec,
|
||||
@@ -60,50 +59,12 @@ use std::{
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::broadcast::{Receiver, Sender};
|
||||
use tokio::time::Duration;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
type ListObjectsV2Info = StorageListObjectsV2Info<ObjectInfo>;
|
||||
type ListObjectVersionsInfo = StorageListObjectVersionsInfo<ObjectInfo>;
|
||||
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, Error>;
|
||||
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
|
||||
|
||||
const LIST_MULTIPART_SETS_CONCURRENCY: usize = 4;
|
||||
|
||||
#[cfg(test)]
|
||||
type TestSetLockers = HashMap<usize, Vec<Arc<dyn rustfs_lock::LockClient>>>;
|
||||
|
||||
#[cfg(test)]
|
||||
static TEST_SET_LOCKERS: std::sync::LazyLock<std::sync::Mutex<TestSetLockers>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
/// Keep a fixture's real locker domain scoped to its owning instance.
|
||||
#[cfg(test)]
|
||||
pub(crate) struct ScopedSetLockers(Arc<InstanceContext>);
|
||||
|
||||
#[cfg(test)]
|
||||
impl ScopedSetLockers {
|
||||
pub(crate) fn install(ctx: Arc<InstanceContext>, lockers: Vec<Arc<dyn rustfs_lock::LockClient>>) -> Self {
|
||||
let key = Arc::as_ptr(&ctx) as usize;
|
||||
let mut domains = TEST_SET_LOCKERS.lock().expect("test set lockers mutex");
|
||||
assert!(!domains.contains_key(&key), "the instance already owns a test locker domain");
|
||||
domains.insert(key, lockers);
|
||||
Self(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for ScopedSetLockers {
|
||||
fn drop(&mut self) {
|
||||
TEST_SET_LOCKERS
|
||||
.lock()
|
||||
.expect("test set lockers mutex")
|
||||
.remove(&(Arc::as_ptr(&self.0) as usize));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_idempotent_delete_prefix_error(err: &Error) -> bool {
|
||||
is_err_object_not_found(err) || is_err_strict_volume_not_found(err)
|
||||
}
|
||||
@@ -305,14 +266,6 @@ impl Sets {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let pool_lockers = TEST_SET_LOCKERS
|
||||
.lock()
|
||||
.expect("test set lockers mutex")
|
||||
.get(&(Arc::as_ptr(&instance_ctx) as usize))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| pool_lockers.clone());
|
||||
|
||||
let set_disks = SetDisks::new_with_instance_ctx(
|
||||
runtime_sources::local_node_name().await,
|
||||
Arc::new(RwLock::new(set_drive)),
|
||||
@@ -364,6 +317,17 @@ impl Sets {
|
||||
&self.ctx
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn stop_endpoint_monitor_for_test(&self) {
|
||||
if let Some(exit_signal) = &self.exit_signal {
|
||||
let _ = exit_signal.send(());
|
||||
// Wait for an in-flight reconnect pass as well as its receiver to exit.
|
||||
while exit_signal.receiver_count() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep simulated peers' metadata ownership separate while sharing disks and lock clients.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_instance_ctx_for_test(&mut self, ctx: Arc<InstanceContext>) {
|
||||
@@ -922,67 +886,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for Sets {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::storage_api_contracts::list::ListOperations for Sets {
|
||||
type Error = Error;
|
||||
type ListObjectsV2Info = ListObjectsV2Info;
|
||||
type ListObjectVersionsInfo = ListObjectVersionsInfo;
|
||||
type ObjectInfoOrErr = ObjectInfoOrErr;
|
||||
type WalkOptions = WalkOptions;
|
||||
type WalkCancellation = CancellationToken;
|
||||
type WalkResultSender = tokio::sync::mpsc::Sender<ObjectInfoOrErr>;
|
||||
|
||||
#[tracing::instrument(level = "trace", skip(self))]
|
||||
async fn list_objects_v2(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
continuation_token: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_keys: i32,
|
||||
fetch_owner: bool,
|
||||
start_after: Option<String>,
|
||||
incl_deleted: bool,
|
||||
) -> Result<ListObjectsV2Info> {
|
||||
self.inner_list_objects_v2(
|
||||
bucket,
|
||||
prefix,
|
||||
continuation_token,
|
||||
delimiter,
|
||||
max_keys,
|
||||
fetch_owner,
|
||||
start_after,
|
||||
incl_deleted,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn list_object_versions(
|
||||
self: Arc<Self>,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
marker: Option<String>,
|
||||
version_marker: Option<String>,
|
||||
delimiter: Option<String>,
|
||||
max_keys: i32,
|
||||
) -> Result<ListObjectVersionsInfo> {
|
||||
self.inner_list_object_versions(bucket, prefix, marker, version_marker, delimiter, max_keys)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn walk(
|
||||
self: Arc<Self>,
|
||||
rx: CancellationToken,
|
||||
bucket: &str,
|
||||
prefix: &str,
|
||||
result: tokio::sync::mpsc::Sender<ObjectInfoOrErr>,
|
||||
opts: WalkOptions,
|
||||
) -> Result<()> {
|
||||
self.walk_internal(rx, bucket, prefix, result, opts).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::storage_api_contracts::multipart::MultipartOperations for Sets {
|
||||
type Error = Error;
|
||||
@@ -1555,9 +1458,7 @@ mod tests {
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::layout::endpoints::SetupType;
|
||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::list::ListOperations as _;
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use rustfs_lock::client::local::LocalClient;
|
||||
use serial_test::serial;
|
||||
|
||||
struct SetupTypeGuard {
|
||||
@@ -2137,100 +2038,6 @@ mod tests {
|
||||
assert_eq!(second.common_prefixes, expected_prefixes[1..]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn sets_list_objects_v2_lists_objects_within_the_pool() {
|
||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
|
||||
let format = FormatV3::new(1, 2);
|
||||
let mut endpoints = Vec::new();
|
||||
let mut disks = Vec::new();
|
||||
|
||||
for disk_idx in 0..2 {
|
||||
let dir = tempfile::tempdir().expect("tempdir should be created");
|
||||
let mut endpoint =
|
||||
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(disk_idx);
|
||||
|
||||
let disk = new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("disk should be created");
|
||||
|
||||
let mut disk_format = format.clone();
|
||||
disk_format.erasure.this = format.erasure.sets[0][disk_idx];
|
||||
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
||||
.await
|
||||
.expect("format should be saved");
|
||||
|
||||
std::mem::forget(dir);
|
||||
endpoints.push(endpoint);
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
|
||||
let set_disks = SetDisks::new(
|
||||
"test-owner".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
endpoints.clone(),
|
||||
format.clone(),
|
||||
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
|
||||
)
|
||||
.await;
|
||||
|
||||
let sets = Arc::new(Sets {
|
||||
id: format.id,
|
||||
disk_set: vec![set_disks],
|
||||
pool_idx: 0,
|
||||
endpoints: PoolEndpoints {
|
||||
legacy: false,
|
||||
set_count: 1,
|
||||
drives_per_set: 2,
|
||||
endpoints: Endpoints::from(endpoints),
|
||||
cmd_line: String::new(),
|
||||
platform: String::new(),
|
||||
},
|
||||
format,
|
||||
parity_count: 1,
|
||||
set_count: 1,
|
||||
set_drive_count: 2,
|
||||
default_parity_count: 1,
|
||||
distribution_algo: DistributionAlgoVersion::V1,
|
||||
exit_signal: None,
|
||||
ctx: bootstrap_ctx(),
|
||||
});
|
||||
|
||||
let bucket = format!("bucket-{}", Uuid::new_v4().simple());
|
||||
let object = format!("object-{}", Uuid::new_v4().simple());
|
||||
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
|
||||
let mut reader = PutObjReader::from_vec(b"hello".to_vec());
|
||||
sets.put_object(&bucket, &object, &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("object should be written");
|
||||
|
||||
let result = sets
|
||||
.clone()
|
||||
.list_objects_v2(&bucket, "", None, None, 1000, false, None, false)
|
||||
.await
|
||||
.expect("pool-level listing should succeed");
|
||||
|
||||
assert_eq!(result.objects.len(), 1);
|
||||
assert_eq!(result.objects[0].name, object);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sets_check_abandoned_parts_rejects_invalid_set_scope() {
|
||||
let format = FormatV3::new(1, 1);
|
||||
|
||||
@@ -958,6 +958,26 @@ fn is_data_movement_rewritten_transition_metadata(object_info: &ObjectInfo, key:
|
||||
})
|
||||
}
|
||||
|
||||
fn data_movement_publication_marker_valid(object_info: &ObjectInfo) -> bool {
|
||||
use crate::disk::multipart_source::{SourceConditionV1, publication_identity_from_metadata};
|
||||
|
||||
let publication = match publication_identity_from_metadata(&object_info.user_defined) {
|
||||
Ok(Some(publication)) => publication,
|
||||
Ok(None) => return true,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if publication.final_data_dir.is_nil() {
|
||||
return false;
|
||||
}
|
||||
SourceConditionV1 {
|
||||
lineage: publication.lineage,
|
||||
generation: publication.source_generation,
|
||||
attempt: publication.attempt,
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, normalize_compression_size: bool) -> bool {
|
||||
[
|
||||
SUFFIX_DATA_MOVED,
|
||||
@@ -972,6 +992,7 @@ fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, norm
|
||||
]
|
||||
.iter()
|
||||
.any(|suffix| is_data_movement_internal_metadata(key, suffix))
|
||||
|| crate::disk::multipart_source::is_publication_metadata_key(key)
|
||||
|| is_data_movement_rewritten_transition_metadata(object_info, key)
|
||||
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM
|
||||
|| key == rustfs_rio::RUSTFS_MULTIPART_CHECKSUM_TYPE
|
||||
@@ -994,6 +1015,14 @@ pub(crate) fn is_equivalent_data_movement_metadata(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy can retain a native publication after changing the current key,
|
||||
// version or placement. Validate that historical record's structure, then
|
||||
// exclude only this per-copy provenance from content equivalence. Actual
|
||||
// current scope/content remain independently compared below; this grants
|
||||
// no native write-quorum proof or Complete replay authority.
|
||||
if !data_movement_publication_marker_valid(source) || !data_movement_publication_marker_valid(target) {
|
||||
return false;
|
||||
}
|
||||
let normalize_compression_size = source.is_compressed() && target.is_compressed();
|
||||
if normalize_compression_size
|
||||
&& !is_compatible_data_movement_marker_presence(
|
||||
@@ -1033,6 +1062,25 @@ pub(crate) fn is_equivalent_data_movement_object_identity(
|
||||
target: &ObjectInfo,
|
||||
compare_mod_time: bool,
|
||||
compare_part_checksums: bool,
|
||||
) -> bool {
|
||||
source.bucket == target.bucket
|
||||
&& source.name == target.name
|
||||
&& is_equivalent_data_movement_object_fields(source, target, compare_mod_time, compare_part_checksums)
|
||||
}
|
||||
|
||||
// Exact-delete capacity reconciliation reads a target at a fixed physical key.
|
||||
// Keep its checked encoded-name contract separate from logical-key equality.
|
||||
pub(crate) fn is_equivalent_data_movement_capacity_target(source: &ObjectInfo, target: &ObjectInfo) -> bool {
|
||||
source.bucket == target.bucket
|
||||
&& source.name == rustfs_utils::path::decode_dir_object(&target.name)
|
||||
&& is_equivalent_data_movement_object_fields(source, target, true, false)
|
||||
}
|
||||
|
||||
fn is_equivalent_data_movement_object_fields(
|
||||
source: &ObjectInfo,
|
||||
target: &ObjectInfo,
|
||||
compare_mod_time: bool,
|
||||
compare_part_checksums: bool,
|
||||
) -> bool {
|
||||
let (Some(source_actual_size), Some(target_actual_size)) = (effective_actual_size(source), effective_actual_size(target))
|
||||
else {
|
||||
@@ -3549,6 +3597,268 @@ mod tests {
|
||||
assert!(!can_replace_stale_data_movement_target(&delete_marker, &opts));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_identity_keeps_logical_directory_and_literal_suffix_distinct() {
|
||||
let fi = FileInfo {
|
||||
version_id: Some(Uuid::from_u128(1)),
|
||||
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||
size: 10,
|
||||
metadata: HashMap::from([("etag".to_owned(), "directory-etag".to_owned())]),
|
||||
..Default::default()
|
||||
};
|
||||
let source = ObjectInfo::from_file_info(&fi, "bucket", "directory/", true);
|
||||
let target = ObjectInfo::from_file_info(&fi, "bucket", &encode_dir_object("directory/"), true);
|
||||
assert_eq!(target.name, "directory/", "real metadata construction returns the logical name");
|
||||
assert!(is_equivalent_data_movement_object_identity(&source, &target, true, false));
|
||||
assert!(is_equivalent_data_movement_capacity_target(&source, &target));
|
||||
|
||||
let literal = ObjectInfo {
|
||||
name: "directory__XLDIR__".to_owned(),
|
||||
..target
|
||||
};
|
||||
assert!(crate::bucket::utils::is_valid_object_name(&literal.name));
|
||||
assert!(crate::bucket::utils::is_valid_object_name(&source.name));
|
||||
assert!(!is_equivalent_data_movement_object_identity(&source, &literal, true, false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_movement_equivalence_validates_each_real_native_multipart_publication() {
|
||||
use crate::disk::DiskAPI as _;
|
||||
use crate::disk::multipart_source::{is_publication_metadata_key, publication_identity_from_metadata};
|
||||
use crate::storage_api_contracts::multipart::MultipartOperations as _;
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||
|
||||
let (_source_dirs, source_disks, source_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
|
||||
let (_target_dirs, target_disks, target_set) = crate::set_disk::hermetic_set_disks_isolated(4).await;
|
||||
let bucket = "native-multipart-migration-equivalence";
|
||||
let object = "native-multipart-migration-equivalence-object";
|
||||
for disk in source_disks.iter().chain(&target_disks) {
|
||||
disk.make_volume(bucket).await.expect("create both real bucket volumes");
|
||||
}
|
||||
let body = vec![0x39; 1 << 20];
|
||||
let upload = source_set
|
||||
.new_multipart_upload(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("real source upload");
|
||||
let part = source_set
|
||||
.put_object_part(
|
||||
bucket,
|
||||
object,
|
||||
&upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(body.clone()),
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("real source part");
|
||||
source_set
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
bucket,
|
||||
object,
|
||||
&upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&ObjectOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("real native source publication");
|
||||
let source = source_set
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("actual committed source metadata");
|
||||
let create_opts = data_movement_new_multipart_opts(&source, 0);
|
||||
let moved_upload = target_set
|
||||
.new_multipart_upload(bucket, object, &create_opts)
|
||||
.await
|
||||
.expect("real temporary transfer upload");
|
||||
let part_opts = ObjectOptions {
|
||||
part_number: Some(source.parts[0].number),
|
||||
preserve_etag: Some(source.parts[0].etag.clone()),
|
||||
data_movement: true,
|
||||
..Default::default()
|
||||
};
|
||||
let part = target_set
|
||||
.put_object_part(
|
||||
bucket,
|
||||
object,
|
||||
&moved_upload.upload_id,
|
||||
1,
|
||||
&mut PutObjReader::from_vec(body.clone()),
|
||||
&part_opts,
|
||||
)
|
||||
.await
|
||||
.expect("real transfer part");
|
||||
let complete_opts = data_movement_complete_multipart_opts(&source, 0, false).expect("compatible transfer options");
|
||||
target_set
|
||||
.clone()
|
||||
.complete_multipart_upload(
|
||||
bucket,
|
||||
object,
|
||||
&moved_upload.upload_id,
|
||||
vec![CompletePart {
|
||||
part_num: part.part_num,
|
||||
etag: part.etag,
|
||||
..Default::default()
|
||||
}],
|
||||
&complete_opts,
|
||||
)
|
||||
.await
|
||||
.expect("real native migration publication");
|
||||
let target = target_set
|
||||
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("actual committed target metadata");
|
||||
let source_publication = publication_identity_from_metadata(&source.user_defined)
|
||||
.expect("source tuple decodes")
|
||||
.expect("source producer retained its native tuple");
|
||||
let target_publication = publication_identity_from_metadata(&target.user_defined)
|
||||
.expect("target tuple decodes")
|
||||
.expect("target producer retained its own native tuple");
|
||||
assert_ne!(
|
||||
source_publication, target_publication,
|
||||
"physical publications have distinct attempts and directories"
|
||||
);
|
||||
assert_ne!(source.data_dir, target.data_dir);
|
||||
assert!(
|
||||
are_equivalent_data_movement_parts(&source.parts, &target.parts),
|
||||
"the real transport preserves each part ETag"
|
||||
);
|
||||
assert!(is_equivalent_data_movement_object(&source, &target));
|
||||
assert_eq!(
|
||||
resolve_data_movement_overwrite_resume_copy(
|
||||
&Error::PreconditionFailed,
|
||||
Ok(Some(target.clone())),
|
||||
&source,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.expect("actual equivalent target resolves the failed create-only condition"),
|
||||
Some(DataMovementCopy::Committed)
|
||||
);
|
||||
for set in [&source_set, &target_set] {
|
||||
let mut reader = set
|
||||
.get_object_reader(bucket, object, None, http::HeaderMap::new(), &ObjectOptions::default())
|
||||
.await
|
||||
.expect("actual source and target bodies");
|
||||
let mut actual = Vec::new();
|
||||
reader.stream.read_to_end(&mut actual).await.expect("read full actual body");
|
||||
assert_eq!(actual, body);
|
||||
}
|
||||
for changed in [
|
||||
"data-dir",
|
||||
"version",
|
||||
"mtime",
|
||||
"bucket",
|
||||
"key",
|
||||
"user-metadata",
|
||||
"etag",
|
||||
"checksum",
|
||||
"parts",
|
||||
"condition",
|
||||
"status",
|
||||
"unknown",
|
||||
] {
|
||||
let mut invalid = target.clone();
|
||||
match changed {
|
||||
"data-dir" => {
|
||||
let mut historical = publication_identity_from_metadata(&invalid.user_defined)
|
||||
.expect("real tuple decodes")
|
||||
.expect("real tuple exists");
|
||||
historical.final_data_dir = Uuid::nil();
|
||||
let encoded = hex_simd::encode_to_string(
|
||||
rmp_serde::to_vec_named(&historical).expect("encode structurally invalid historical tuple"),
|
||||
hex_simd::AsciiCase::Lower,
|
||||
);
|
||||
for (key, value) in Arc::make_mut(&mut invalid.user_defined).iter_mut() {
|
||||
if is_publication_metadata_key(key) {
|
||||
value.clone_from(&encoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
"version" => invalid.version_id = Some(Uuid::new_v4()),
|
||||
"mtime" => invalid.mod_time = invalid.mod_time.map(|time| time + time::Duration::seconds(1)),
|
||||
"bucket" => invalid.bucket.push_str("-other"),
|
||||
"key" => invalid.name.push_str("-other"),
|
||||
"etag" => invalid.etag = Some("different-current-etag".to_owned()),
|
||||
"checksum" => invalid.checksum = Some(Bytes::from_static(b"different-current-checksum")),
|
||||
"parts" => Arc::make_mut(&mut invalid.parts)[0].etag.push_str("-other"),
|
||||
"user-metadata" => {
|
||||
Arc::make_mut(&mut invalid.user_defined).insert("x-amz-meta-extra".to_owned(), "changed".to_owned());
|
||||
}
|
||||
suffix => rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut invalid.user_defined),
|
||||
&format!("multipart-source-{suffix}"),
|
||||
"unexpected".to_owned(),
|
||||
),
|
||||
}
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&source, &invalid),
|
||||
"{changed} cannot bypass metadata equivalence"
|
||||
);
|
||||
}
|
||||
let mut malformed = target.clone();
|
||||
for (key, value) in Arc::make_mut(&mut malformed.user_defined).iter_mut() {
|
||||
if is_publication_metadata_key(key) {
|
||||
*value = "malformed".to_owned();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&source, &malformed),
|
||||
"malformed publication cannot be ignored"
|
||||
);
|
||||
let mut conflicting = target.clone();
|
||||
let aliases = conflicting
|
||||
.user_defined
|
||||
.keys()
|
||||
.filter(|key| is_publication_metadata_key(key))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(aliases.len(), 2, "actual writer stores both canonical publication aliases");
|
||||
Arc::make_mut(&mut conflicting.user_defined).insert(aliases[0].clone(), "conflicting".to_owned());
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&source, &conflicting),
|
||||
"conflicting aliases cannot be ignored"
|
||||
);
|
||||
let mut malformed_source = source.clone();
|
||||
for (key, value) in Arc::make_mut(&mut malformed_source.user_defined).iter_mut() {
|
||||
if is_publication_metadata_key(key) {
|
||||
*value = "malformed-source".to_owned();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!is_equivalent_data_movement_object(&malformed_source, &target),
|
||||
"a malformed present source publication cannot be projected away"
|
||||
);
|
||||
let mut takeover = target.clone();
|
||||
takeover.mod_time = source.mod_time.map(|time| time + time::Duration::seconds(1));
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut takeover.user_defined),
|
||||
SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
data_movement_upload_identity(&source),
|
||||
);
|
||||
assert!(is_data_movement_upload_takeover_target(&source, &takeover, true));
|
||||
rustfs_utils::http::insert_str(
|
||||
Arc::make_mut(&mut takeover.user_defined),
|
||||
SUFFIX_DATA_MOVEMENT_UPLOAD,
|
||||
"different-transfer-identity".to_owned(),
|
||||
);
|
||||
assert!(
|
||||
!is_data_movement_upload_takeover_target(&source, &takeover, true),
|
||||
"historical publication cannot bypass the exact current transfer identity"
|
||||
);
|
||||
let mut legacy = target.clone();
|
||||
Arc::make_mut(&mut legacy.user_defined).retain(|key, _| !is_publication_metadata_key(key));
|
||||
assert!(
|
||||
is_equivalent_data_movement_object(&source, &legacy),
|
||||
"a legacy destination may lack per-copy provenance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_equivalent_data_movement_object_accepts_matching_metadata() {
|
||||
let version_id = Uuid::nil();
|
||||
|
||||
@@ -216,7 +216,7 @@ pub async fn lookup_degraded_bucket_usage_baseline(store: Arc<ECStore>, bucket:
|
||||
// Stale or empty cache: refresh through the TTL-bounded loader. A failed
|
||||
// refresh carries the previous baseline forward, so quota admission keeps
|
||||
// its last grounded values through a backend read outage.
|
||||
let _ = load_data_usage_from_backend_cached(store).await;
|
||||
let _ = project_data_usage_from_backend_cached(store, |_| ()).await;
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
cache
|
||||
.as_ref()
|
||||
@@ -237,31 +237,34 @@ struct CachedDataUsageSnapshot {
|
||||
}
|
||||
|
||||
impl CachedDataUsageSnapshot {
|
||||
fn result(&self) -> Result<DataUsageInfo, Error> {
|
||||
fn result<T>(&self, project: impl FnOnce(&DataUsageInfo) -> T) -> Result<T, Error> {
|
||||
self.info
|
||||
.clone()
|
||||
.as_ref()
|
||||
.map(project)
|
||||
.ok_or_else(|| Error::other("data usage snapshot load recently failed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_cached_data_usage_snapshot(
|
||||
fn fresh_cached_data_usage_snapshot<T>(
|
||||
cache: &Option<CachedDataUsageSnapshot>,
|
||||
now: tokio::time::Instant,
|
||||
ttl: Duration,
|
||||
) -> Option<Result<DataUsageInfo, Error>> {
|
||||
project: impl FnOnce(&DataUsageInfo) -> T,
|
||||
) -> Option<Result<T, Error>> {
|
||||
cache
|
||||
.as_ref()
|
||||
.filter(|cached| now.duration_since(cached.loaded_at) < ttl)
|
||||
.map(CachedDataUsageSnapshot::result)
|
||||
.map(|cached| cached.result(project))
|
||||
}
|
||||
|
||||
fn cache_data_usage_snapshot_result(
|
||||
fn cache_data_usage_snapshot_result<T>(
|
||||
cache: &mut Option<CachedDataUsageSnapshot>,
|
||||
result: Result<LoadedUsageBaseline, Error>,
|
||||
loaded_at: tokio::time::Instant,
|
||||
refresh_generation: u64,
|
||||
current_generation: u64,
|
||||
) -> Option<Result<DataUsageInfo, Error>> {
|
||||
project: impl FnOnce(&DataUsageInfo) -> T,
|
||||
) -> Option<Result<T, Error>> {
|
||||
if current_generation != refresh_generation {
|
||||
return None;
|
||||
}
|
||||
@@ -280,12 +283,13 @@ fn cache_data_usage_snapshot_result(
|
||||
degraded_baseline.entry(bucket.clone()).or_insert(*size);
|
||||
}
|
||||
}
|
||||
let projected = project(&info);
|
||||
*cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(info.clone()),
|
||||
info: Some(info),
|
||||
loaded_at,
|
||||
degraded_baseline,
|
||||
});
|
||||
Ok(info)
|
||||
Ok(projected)
|
||||
}
|
||||
Err(e) => {
|
||||
// Keep the previous baseline through a failed refresh: quota
|
||||
@@ -1486,12 +1490,21 @@ async fn normalize_loaded_data_usage(
|
||||
/// `save_data_usage_in_backend` invalidates the cache so a fresh scanner save
|
||||
/// is visible immediately in the saving process.
|
||||
pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<DataUsageInfo, Error> {
|
||||
project_data_usage_from_backend_cached(store, DataUsageInfo::clone).await
|
||||
}
|
||||
|
||||
// Project under the cache guard so scalar readers never copy the snapshot's
|
||||
// bucket maps. The owned API shares the same TTL, refresh and invalidation path.
|
||||
async fn project_data_usage_from_backend_cached<T>(
|
||||
store: Arc<ECStore>,
|
||||
project: impl Fn(&DataUsageInfo) -> T,
|
||||
) -> Result<T, Error> {
|
||||
let ttl = Duration::from_secs(DATA_USAGE_CACHE_TTL_SECS);
|
||||
|
||||
loop {
|
||||
{
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1503,7 +1516,7 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
|
||||
.await;
|
||||
{
|
||||
let cache = data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, &project) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1512,9 +1525,14 @@ pub async fn load_data_usage_from_backend_cached(store: Arc<ECStore>) -> Result<
|
||||
let result = load_data_usage_from_backend_with_baseline(store.clone()).await;
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let mut cache = data_usage_snapshot_cache().write().await;
|
||||
if let Some(result) =
|
||||
cache_data_usage_snapshot_result(&mut cache, result, loaded_at, refresh_generation, data_usage_snapshot_generation())
|
||||
{
|
||||
if let Some(result) = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
result,
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
&project,
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
drop(cache);
|
||||
@@ -1532,7 +1550,8 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
|
||||
loop {
|
||||
{
|
||||
let cache = admin_data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1543,7 +1562,8 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
|
||||
.await;
|
||||
{
|
||||
let cache = admin_data_usage_snapshot_cache().read().await;
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl) {
|
||||
if let Some(result) = fresh_cached_data_usage_snapshot(&cache, tokio::time::Instant::now(), ttl, DataUsageInfo::clone)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1564,6 +1584,7 @@ pub async fn load_admin_data_usage_from_backend_cached(store: Arc<ECStore>) -> R
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
admin_data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
@@ -2277,8 +2298,10 @@ async fn get_persisted_bucket_usage(bucket: &str) -> Option<u64> {
|
||||
}
|
||||
|
||||
async fn persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
let info = load_data_usage_from_backend_cached(store).await.ok()?;
|
||||
info.buckets_usage.get(bucket).map(|usage| usage.size)
|
||||
project_data_usage_from_backend_cached(store, |info| info.buckets_usage.get(bucket).map(|usage| usage.size))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn reload_persisted_bucket_usage(store: Arc<ECStore>, bucket: &str) -> Option<u64> {
|
||||
@@ -2375,6 +2398,7 @@ where
|
||||
tokio::time::Instant::now(),
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
|_| (),
|
||||
)
|
||||
.is_some()
|
||||
}
|
||||
@@ -2891,6 +2915,8 @@ pub async fn init_compression_total_memory_from_backend(store: Arc<ECStore>) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod snapshot_projection;
|
||||
|
||||
use super::*;
|
||||
use crate::layout::endpoints::EndpointServerPools;
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
@@ -4572,15 +4598,29 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache");
|
||||
assert!(matches!(first, Err(Error::ErasureReadQuorum)));
|
||||
|
||||
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
|
||||
.expect("the failed load must remain cached within the TTL");
|
||||
let cached = fresh_cached_data_usage_snapshot(
|
||||
&cache,
|
||||
loaded_at + Duration::from_secs(1),
|
||||
Duration::from_secs(30),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("the failed load must remain cached within the TTL");
|
||||
assert!(cached.is_err());
|
||||
|
||||
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(30), Duration::from_secs(30)).is_none());
|
||||
assert!(
|
||||
fresh_cached_data_usage_snapshot(
|
||||
&cache,
|
||||
loaded_at + Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
DataUsageInfo::clone
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4601,14 +4641,20 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache")
|
||||
.expect("successful load must be returned");
|
||||
assert_snapshot_bucket(&first, "bucket");
|
||||
|
||||
let cached = fresh_cached_data_usage_snapshot(&cache, loaded_at + Duration::from_secs(1), Duration::from_secs(30))
|
||||
.expect("successful load must remain cached within the TTL")
|
||||
.expect("cached success must remain successful");
|
||||
let cached = fresh_cached_data_usage_snapshot(
|
||||
&cache,
|
||||
loaded_at + Duration::from_secs(1),
|
||||
Duration::from_secs(30),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("successful load must remain cached within the TTL")
|
||||
.expect("cached success must remain successful");
|
||||
assert_snapshot_bucket(&cached, "bucket");
|
||||
}
|
||||
|
||||
@@ -4633,6 +4679,7 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
)
|
||||
.expect("an uninterrupted refresh should populate the cache")
|
||||
.expect("successful load must be returned");
|
||||
@@ -4666,6 +4713,7 @@ mod tests {
|
||||
loaded_at,
|
||||
refresh_generation,
|
||||
data_usage_snapshot_generation(),
|
||||
DataUsageInfo::clone,
|
||||
);
|
||||
|
||||
assert!(stale_result.is_none());
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scalar_projection_borrows_snapshot_and_preserves_unknown_and_zero() {
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
|
||||
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
|
||||
let cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(info),
|
||||
loaded_at,
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
let stored = cache
|
||||
.as_ref()
|
||||
.expect("cached snapshot")
|
||||
.info
|
||||
.as_ref()
|
||||
.expect("known snapshot");
|
||||
let ttl = Duration::from_secs(30);
|
||||
|
||||
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
|
||||
let value = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, |info| {
|
||||
assert!(std::ptr::eq(info, stored), "projection must borrow the cached snapshot");
|
||||
info.buckets_usage.get(bucket).map(|usage| usage.size)
|
||||
})
|
||||
.expect("fresh snapshot")
|
||||
.expect("successful snapshot");
|
||||
let owned = fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, DataUsageInfo::clone)
|
||||
.expect("fresh snapshot")
|
||||
.expect("successful snapshot");
|
||||
assert_eq!(value, expected);
|
||||
assert_eq!(value, owned.buckets_usage.get(bucket).map(|usage| usage.size));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_is_skipped_for_missing_failed_expired_or_invalidated_snapshots() {
|
||||
let loaded_at = tokio::time::Instant::now();
|
||||
let ttl = Duration::from_secs(30);
|
||||
let never_project = |_: &DataUsageInfo| -> u64 { panic!("unusable snapshots must not be projected") };
|
||||
assert!(fresh_cached_data_usage_snapshot(&None, loaded_at, ttl, never_project).is_none());
|
||||
|
||||
let mut cache = None;
|
||||
let failure = cache_data_usage_snapshot_result(&mut cache, Err(Error::ErasureReadQuorum), loaded_at, 1, 1, never_project)
|
||||
.expect("matching generation");
|
||||
assert!(matches!(failure, Err(Error::ErasureReadQuorum)));
|
||||
assert!(
|
||||
fresh_cached_data_usage_snapshot(&cache, loaded_at, ttl, never_project)
|
||||
.expect("negative cache remains fresh")
|
||||
.is_err()
|
||||
);
|
||||
|
||||
cache = Some(CachedDataUsageSnapshot {
|
||||
info: Some(data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH)),
|
||||
loaded_at,
|
||||
degraded_baseline: HashMap::new(),
|
||||
});
|
||||
assert!(fresh_cached_data_usage_snapshot(&cache, loaded_at + ttl, ttl, never_project).is_none());
|
||||
cache = None;
|
||||
let rejected = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Ok(LoadedUsageBaseline {
|
||||
info: data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH),
|
||||
degraded_baseline: HashMap::new(),
|
||||
observed_unavailable: false,
|
||||
}),
|
||||
loaded_at,
|
||||
1,
|
||||
2,
|
||||
never_project,
|
||||
);
|
||||
assert!(rejected.is_none());
|
||||
assert!(cache.is_none(), "rejected generation must not republish stale data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_publication_moves_bucket_maps_into_cache() {
|
||||
let info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
|
||||
let original_key = info.buckets_usage.get_key_value("known").expect("source bucket").0.as_ptr();
|
||||
let mut cache = None;
|
||||
let usage = cache_data_usage_snapshot_result(
|
||||
&mut cache,
|
||||
Ok(LoadedUsageBaseline {
|
||||
info,
|
||||
degraded_baseline: HashMap::new(),
|
||||
observed_unavailable: false,
|
||||
}),
|
||||
tokio::time::Instant::now(),
|
||||
1,
|
||||
1,
|
||||
|info| {
|
||||
assert_eq!(
|
||||
info.buckets_usage
|
||||
.get_key_value("known")
|
||||
.expect("projected bucket")
|
||||
.0
|
||||
.as_ptr(),
|
||||
original_key
|
||||
);
|
||||
info.buckets_usage.get("known").map(|usage| usage.size)
|
||||
},
|
||||
)
|
||||
.expect("matching generation")
|
||||
.expect("successful publication");
|
||||
assert_eq!(usage, Some(42));
|
||||
let cached = cache
|
||||
.as_ref()
|
||||
.expect("published cache")
|
||||
.info
|
||||
.as_ref()
|
||||
.expect("published snapshot");
|
||||
assert_eq!(
|
||||
cached.buckets_usage.get_key_value("known").expect("cached bucket").0.as_ptr(),
|
||||
original_key
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn persisted_scalar_projection_shares_cache_refresh_and_invalidation() {
|
||||
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
|
||||
use crate::config::com::save_config;
|
||||
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
let mut info = data_usage_info_for_test("known", 1, 42, SystemTime::UNIX_EPOCH);
|
||||
info.buckets_usage.insert("empty".to_string(), BucketUsageInfo::default());
|
||||
info.bucket_sizes.insert("empty".to_string(), 0);
|
||||
info.buckets_count = 2;
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&info).expect("encode snapshot"),
|
||||
)
|
||||
.await
|
||||
.expect("persist snapshot");
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
|
||||
for (bucket, expected) in [("known", Some(42)), ("empty", Some(0)), ("missing", None)] {
|
||||
assert_eq!(persisted_bucket_usage(store.clone(), bucket).await, expected);
|
||||
let owned = load_data_usage_from_backend_cached(store.clone())
|
||||
.await
|
||||
.expect("owned cached snapshot");
|
||||
assert_eq!(expected, owned.buckets_usage.get(bucket).map(|usage| usage.size));
|
||||
}
|
||||
info.buckets_usage.get_mut("known").expect("known bucket").size = 84;
|
||||
info.bucket_sizes.insert("known".to_string(), 84);
|
||||
save_config(
|
||||
store.clone(),
|
||||
DATA_USAGE_OBJ_NAME_PATH.as_str(),
|
||||
serde_json::to_vec(&info).expect("encode new snapshot"),
|
||||
)
|
||||
.await
|
||||
.expect("persist new snapshot without invalidating cache");
|
||||
assert_eq!(
|
||||
persisted_bucket_usage(store.clone(), "known").await,
|
||||
Some(42),
|
||||
"warm reads reuse the TTL cache"
|
||||
);
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
assert_eq!(
|
||||
persisted_bucket_usage(store.clone(), "known").await,
|
||||
Some(84),
|
||||
"invalidation exposes the new snapshot"
|
||||
);
|
||||
invalidate_data_usage_snapshot_cache().await;
|
||||
if let Some(cancel) = store.ctx.background_cancel_token() {
|
||||
cancel.cancel();
|
||||
}
|
||||
for pool in &store.pools {
|
||||
for set in &pool.disk_set {
|
||||
let disks = set.disks.read().await.clone();
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.close().await.expect("stop fixture disk monitoring");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,7 +440,7 @@ pub(crate) trait DiskStoreRenameDataExt {
|
||||
/// Run a mutation in an owned task when a caller supplied publication guard.
|
||||
/// RPC cancellation drops only the waiter; the mutation owner keeps the guard
|
||||
/// until its operation has returned, including any detached blocking syscall.
|
||||
async fn run_owned_mutation<T, F, Fut>(external_guard: Option<Arc<dyn Send + Sync>>, operation: F) -> Result<T>
|
||||
pub(crate) async fn run_owned_mutation<T, F, Fut>(external_guard: Option<Arc<dyn Send + Sync>>, operation: F) -> Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Fut + Send + 'static,
|
||||
@@ -803,12 +803,45 @@ fn unpack_health_state(snapshot: u64) -> (RuntimeDriveHealthState, u32) {
|
||||
(RuntimeDriveHealthState::from_u32((snapshot >> 32) as u32), snapshot as u32)
|
||||
}
|
||||
|
||||
/// One upload-scoped observer; it is never part of production disk state.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScopedMultipartCleanupCompletions {
|
||||
path: String,
|
||||
delete_paths_returns: AtomicU64,
|
||||
delete_returns: AtomicU64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ScopedMultipartCleanupCompletions {
|
||||
fn observes(&self, operation: &str, volume: &str, path: &str) -> bool {
|
||||
volume == super::RUSTFS_META_MULTIPART_BUCKET
|
||||
&& match operation {
|
||||
"delete" => path == self.path,
|
||||
"delete_paths" => path
|
||||
.strip_prefix(self.path.as_str())
|
||||
.is_some_and(|suffix| suffix.starts_with('/') && suffix.len() > 1),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn completed(&self, operation: &str) -> u64 {
|
||||
match operation {
|
||||
"delete_paths" => self.delete_paths_returns.load(Ordering::Acquire),
|
||||
"delete" => self.delete_returns.load(Ordering::Acquire),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DiskHealthMetricEpoch {
|
||||
/// Preallocated per-operation metrics for built-in disk operation names.
|
||||
operation_metrics: Box<[DiskOperationMetricEntry]>,
|
||||
/// Fallback for tests or future extension operations outside DISK_OPERATION_NAMES.
|
||||
fallback_operation_metrics: StdRwLock<HashMap<&'static str, Arc<DiskOperationMetrics>>>,
|
||||
#[cfg(test)]
|
||||
multipart_cleanup_observer: std::sync::Mutex<Option<std::sync::Weak<ScopedMultipartCleanupCompletions>>>,
|
||||
/// Caller API operations currently executing through the disk health wrapper.
|
||||
api_waiting: AtomicU32,
|
||||
/// Operations rejected because the disk was unavailable for the caller.
|
||||
@@ -834,6 +867,8 @@ impl Default for DiskHealthMetricEpoch {
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice(),
|
||||
fallback_operation_metrics: StdRwLock::new(HashMap::new()),
|
||||
#[cfg(test)]
|
||||
multipart_cleanup_observer: std::sync::Mutex::new(None),
|
||||
api_waiting: AtomicU32::new(0),
|
||||
total_errors_availability: AtomicU64::new(0),
|
||||
total_errors_timeout: AtomicU64::new(0),
|
||||
@@ -893,6 +928,46 @@ impl DiskHealthMetricEpoch {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn observe_multipart_cleanup(&self, path: &str) -> Arc<ScopedMultipartCleanupCompletions> {
|
||||
assert!(!path.is_empty() && !path.ends_with('/'), "observe one exact canonical upload root");
|
||||
let scope = Arc::new(ScopedMultipartCleanupCompletions {
|
||||
path: path.to_owned(),
|
||||
delete_paths_returns: AtomicU64::new(0),
|
||||
delete_returns: AtomicU64::new(0),
|
||||
});
|
||||
let mut observer = self.multipart_cleanup_observer.lock().expect("test cleanup observer lock");
|
||||
assert!(
|
||||
observer.as_ref().and_then(std::sync::Weak::upgrade).is_none(),
|
||||
"one observer per physical wrapper"
|
||||
);
|
||||
*observer = Some(Arc::downgrade(&scope));
|
||||
scope
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn matching_multipart_cleanup<'a>(
|
||||
&self,
|
||||
operation: &str,
|
||||
volume: &str,
|
||||
paths: impl IntoIterator<Item = &'a str>,
|
||||
) -> Option<Arc<ScopedMultipartCleanupCompletions>> {
|
||||
let scope = self
|
||||
.multipart_cleanup_observer
|
||||
.lock()
|
||||
.expect("test cleanup observer lock")
|
||||
.as_ref()
|
||||
.and_then(std::sync::Weak::upgrade)?;
|
||||
let mut nonempty = false;
|
||||
for path in paths {
|
||||
if !scope.observes(operation, volume, path) {
|
||||
return None;
|
||||
}
|
||||
nonempty = true;
|
||||
}
|
||||
nonempty.then_some(scope)
|
||||
}
|
||||
|
||||
fn record_availability_error(&self) {
|
||||
self.total_errors_availability.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1160,7 +1235,13 @@ impl DiskHealthTracker {
|
||||
}
|
||||
|
||||
pub(crate) fn metric_epoch_for_reconnect(&self) -> Self {
|
||||
Self::new()
|
||||
let health = Self::new();
|
||||
// Explicit test faults stay attached to the drive across handle renewal.
|
||||
#[cfg(test)]
|
||||
if self.test_forced_offline.load(Ordering::Acquire) {
|
||||
health.force_offline_for_test();
|
||||
}
|
||||
health
|
||||
}
|
||||
|
||||
pub fn last_capacity_snapshot(&self) -> Option<(u64, u64, u64, u64)> {
|
||||
@@ -1570,6 +1651,52 @@ impl LocalDiskWrapper {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Bound the waiter while the physical recovery task retains its instance owner.
|
||||
pub(crate) async fn recover_multipart_source_with_namespace_owner(
|
||||
&self,
|
||||
request: super::multipart_source::MultipartSourceRequest,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<super::multipart_source::MultipartSourceResponse> {
|
||||
self.recover_multipart_source_owned(request, namespace_owner, get_max_timeout_duration())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_multipart_source_owned(
|
||||
&self,
|
||||
request: super::multipart_source::MultipartSourceRequest,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
wait_timeout: Duration,
|
||||
) -> Result<super::multipart_source::MultipartSourceResponse> {
|
||||
request.validate()?;
|
||||
let mutation = request.operation.is_mutation();
|
||||
let operation = self.clone();
|
||||
let owner = if mutation {
|
||||
Some(namespace_owner.unwrap_or_else(|| Arc::new(()) as Arc<dyn Send + Sync>))
|
||||
} else {
|
||||
namespace_owner
|
||||
};
|
||||
let owned = run_owned_mutation(owner, move || async move {
|
||||
operation
|
||||
.track_disk_health_mutation(
|
||||
"recover_multipart_source",
|
||||
if mutation {
|
||||
DiskMetricMutation::Write
|
||||
} else {
|
||||
DiskMetricMutation::None
|
||||
},
|
||||
|| async { operation.disk.recover_multipart_source(request).await },
|
||||
if mutation { Duration::ZERO } else { wait_timeout },
|
||||
)
|
||||
.await
|
||||
});
|
||||
if wait_timeout.is_zero() {
|
||||
return owned.await;
|
||||
}
|
||||
// Dropping this waiter detaches the owned JoinHandle. The task keeps
|
||||
// its namespace owner and resource unit until the native tail drains.
|
||||
time::timeout(wait_timeout, owned).await.map_err(|_| DiskError::Timeout)?
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_reconnect_state(
|
||||
disk: Arc<LocalDisk>,
|
||||
health_check: bool,
|
||||
@@ -2034,6 +2161,11 @@ impl LocalDiskWrapper {
|
||||
self.metrics.metrics_snapshot()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn observe_multipart_cleanup_for_test(&self, path: &str) -> Arc<ScopedMultipartCleanupCompletions> {
|
||||
self.metrics.observe_multipart_cleanup(path)
|
||||
}
|
||||
|
||||
fn record_result_error_metrics<T>(&self, result: &Result<T>) {
|
||||
match result {
|
||||
Err(DiskError::Timeout) => self.metrics.record_timeout_error(),
|
||||
@@ -2538,6 +2670,11 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn is_online(&self) -> bool {
|
||||
// A matching format does not make a handle usable while its I/O gate is faulty.
|
||||
// Reconnect must renew it even if its old recovery monitor has stopped.
|
||||
if self.health.is_faulty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(Some(disk_id)) = self.disk.get_disk_id().await else {
|
||||
return false;
|
||||
};
|
||||
@@ -2639,15 +2776,6 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
})
|
||||
}
|
||||
|
||||
async fn bucket_creation_witness(&self, volume: &str) -> Result<Option<super::os::NativeBucketCreationWitness>> {
|
||||
self.track_disk_health_with_op(
|
||||
"bucket_creation_witness",
|
||||
|| async { self.disk.bucket_creation_witness(volume).await },
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn make_volume(&self, volume: &str) -> Result<()> {
|
||||
// Scoped heal must drain directory creation before releasing its lifecycle owner.
|
||||
let timeout = if crate::store::bucket_heal_scope(volume).is_some() {
|
||||
@@ -2747,13 +2875,23 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete_paths(&self, volume: &str, paths: &[String]) -> Result<()> {
|
||||
self.track_disk_health_mutation(
|
||||
"delete_paths",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.disk.delete_paths(volume, paths).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
#[cfg(test)]
|
||||
let observed = self
|
||||
.metrics
|
||||
.matching_multipart_cleanup("delete_paths", volume, paths.iter().map(String::as_str));
|
||||
let result = self
|
||||
.track_disk_health_mutation(
|
||||
"delete_paths",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.disk.delete_paths(volume, paths).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
if let Some(scope) = observed {
|
||||
scope.delete_paths_returns.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn acquire_snapshot_lease(&self, volume: &str, path: &str) -> Result<SnapshotLeaseToken> {
|
||||
@@ -2927,6 +3065,70 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_data_with_pool_meta_cas(
|
||||
&self,
|
||||
src_volume: &str,
|
||||
src_path: &str,
|
||||
fi: FileInfo,
|
||||
dst_volume: &str,
|
||||
dst_path: &str,
|
||||
) -> Result<RenameDataResp> {
|
||||
if dst_volume != super::RUSTFS_META_BUCKET
|
||||
|| dst_path != "pool.bin"
|
||||
|| !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, super::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.rename_data_borrowed(src_volume, src_path, &fi, dst_volume, dst_path)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn undo_pool_meta_write(&self, volume: &str, path: &str, fi: FileInfo, opts: DeleteOptions) -> Result<()> {
|
||||
if volume != super::RUSTFS_META_BUCKET
|
||||
|| path != "pool.bin"
|
||||
|| !opts.undo_write
|
||||
|| !rustfs_utils::http::metadata_compat::contains_key_str(&fi.metadata, super::POOL_META_NATIVE_CAS_METADATA_SUFFIX)
|
||||
{
|
||||
return Err(DiskError::UnsupportedDisk);
|
||||
}
|
||||
self.undo_write_with_namespace_owner(volume, path, fi, opts, None).await
|
||||
}
|
||||
|
||||
async fn pool_meta_native_recovery(
|
||||
&self,
|
||||
request: super::PoolMetaNativeRecoveryRequest,
|
||||
) -> Result<super::PoolMetaNativeRecoveryResponse> {
|
||||
let mutation = matches!(&request, super::PoolMetaNativeRecoveryRequest::Apply { .. });
|
||||
let operation = self.clone();
|
||||
let owner = mutation.then(|| Arc::new(()) as Arc<dyn Send + Sync>);
|
||||
run_owned_mutation(owner, move || async move {
|
||||
operation
|
||||
.track_disk_health_mutation(
|
||||
"pool_meta_native_recovery",
|
||||
if mutation {
|
||||
DiskMetricMutation::Write
|
||||
} else {
|
||||
DiskMetricMutation::None
|
||||
},
|
||||
|| async { operation.disk.pool_meta_native_recovery(request).await },
|
||||
if mutation {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
get_max_timeout_duration()
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn recover_multipart_source(
|
||||
&self,
|
||||
request: super::multipart_source::MultipartSourceRequest,
|
||||
) -> Result<super::multipart_source::MultipartSourceResponse> {
|
||||
self.recover_multipart_source_with_namespace_owner(request, None).await
|
||||
}
|
||||
|
||||
async fn list_dir(&self, origvolume: &str, volume: &str, dir_path: &str, count: i32) -> Result<Vec<String>> {
|
||||
self.track_disk_health_with_op_and_timeout_action(
|
||||
"list_dir",
|
||||
@@ -3096,13 +3298,23 @@ impl DiskAPI for LocalDiskWrapper {
|
||||
}
|
||||
|
||||
async fn delete(&self, volume: &str, path: &str, opt: DeleteOptions) -> Result<()> {
|
||||
self.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.cleanup_delete(volume, path, opt, None).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await
|
||||
#[cfg(test)]
|
||||
let observed = self
|
||||
.metrics
|
||||
.matching_multipart_cleanup("delete", volume, std::iter::once(path));
|
||||
let result = self
|
||||
.track_cleanup_mutation(
|
||||
"delete",
|
||||
DiskMetricMutation::Delete,
|
||||
|| async { self.cleanup_delete(volume, path, opt, None).await },
|
||||
get_max_timeout_duration(),
|
||||
)
|
||||
.await;
|
||||
#[cfg(test)]
|
||||
if let Some(scope) = observed {
|
||||
scope.delete_returns.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn verify_file(&self, volume: &str, path: &str, fi: &FileInfo) -> Result<CheckPartsResp> {
|
||||
@@ -3228,6 +3440,100 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_cleanup_completion_receipts_are_path_volume_operation_and_return_scoped() {
|
||||
let dir = tempfile::tempdir().expect("cleanup receipt disk root");
|
||||
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 disk root")).expect("local endpoint");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("actual local disk"));
|
||||
let wrapper = LocalDiskWrapper::new(disk.clone(), false);
|
||||
let path = format!("{}/{}", "a".repeat(64), Uuid::new_v4());
|
||||
let scope = wrapper.observe_multipart_cleanup_for_test(&path);
|
||||
let source = disk
|
||||
.get_object_path_for_io(super::super::RUSTFS_META_MULTIPART_BUCKET, &path)
|
||||
.expect("exact source root");
|
||||
std::fs::create_dir_all(&source).expect("actual orphan upload root");
|
||||
std::fs::write(source.join("part.1.meta"), b"selected part record").expect("actual selected cleanup child");
|
||||
std::fs::write(source.join("sentinel"), b"retained root child").expect("actual root child");
|
||||
let recursive = DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tmp = disk
|
||||
.get_object_path_for_io(super::super::RUSTFS_META_TMP_BUCKET, &path)
|
||||
.expect("same spelling in TMP");
|
||||
std::fs::create_dir_all(&tmp).expect("actual old TMP cleanup");
|
||||
wrapper
|
||||
.delete(super::super::RUSTFS_META_TMP_BUCKET, &path, recursive.clone())
|
||||
.await
|
||||
.expect("actual unrelated TMP deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 0, "old TMP completion cannot credit the MPI phase");
|
||||
let sibling = format!("{}/{}", "a".repeat(64), Uuid::new_v4());
|
||||
let sibling_root = disk
|
||||
.get_object_path_for_io(super::super::RUSTFS_META_MULTIPART_BUCKET, &sibling)
|
||||
.expect("different upload");
|
||||
std::fs::create_dir_all(&sibling_root).expect("actual different upload root");
|
||||
wrapper
|
||||
.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &sibling, recursive.clone())
|
||||
.await
|
||||
.expect("actual unrelated upload deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 0, "another upload cannot credit the observed phase");
|
||||
assert_eq!(
|
||||
wrapper
|
||||
.read_all(super::super::RUSTFS_META_MULTIPART_BUCKET, &format!("{path}/part.1.meta"))
|
||||
.await
|
||||
.expect("actual same-scope read"),
|
||||
Bytes::from_static(b"selected part record")
|
||||
);
|
||||
assert_eq!(scope.completed("read_all"), 0);
|
||||
assert_eq!(scope.completed("delete_paths"), 0, "other operations do not credit cleanup");
|
||||
wrapper
|
||||
.delete_paths(super::super::RUSTFS_META_MULTIPART_BUCKET, &[])
|
||||
.await
|
||||
.expect("empty actual cleanup returns");
|
||||
assert_eq!(scope.completed("delete_paths"), 0, "empty cleanup has no scoped part observation");
|
||||
std::fs::write(source.join("obsolete"), b"old child").expect("actual obsolete source child");
|
||||
wrapper
|
||||
.delete(
|
||||
super::super::RUSTFS_META_MULTIPART_BUCKET,
|
||||
&format!("{path}/obsolete"),
|
||||
DeleteOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("actual same-upload child deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 0, "a descendant delete cannot credit upload-root cleanup");
|
||||
wrapper
|
||||
.delete_paths(super::super::RUSTFS_META_MULTIPART_BUCKET, &[format!("{path}/part.1.meta")])
|
||||
.await
|
||||
.expect("actual selected source child deletion returns");
|
||||
assert_eq!(scope.completed("delete_paths"), 1);
|
||||
assert_eq!(scope.completed("delete"), 0, "part cleanup cannot credit root cleanup");
|
||||
let admission = wrapper.disk_id.write().await;
|
||||
{
|
||||
let cancelled = wrapper.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &path, recursive.clone());
|
||||
tokio::pin!(cancelled);
|
||||
assert!(futures::poll!(&mut cancelled).is_pending());
|
||||
assert_eq!(
|
||||
wrapper.metrics_snapshot().total_waiting,
|
||||
0,
|
||||
"the real wrapper is still in stale-check admission"
|
||||
);
|
||||
assert_eq!(scope.completed("delete"), 0);
|
||||
}
|
||||
drop(admission);
|
||||
assert_eq!(scope.completed("delete"), 0, "cancelled wrappers cannot fabricate a return receipt");
|
||||
assert!(source.exists());
|
||||
let admission = wrapper.disk_id.write().await;
|
||||
let deletion = wrapper.delete(super::super::RUSTFS_META_MULTIPART_BUCKET, &path, recursive);
|
||||
tokio::pin!(deletion);
|
||||
assert!(futures::poll!(&mut deletion).is_pending());
|
||||
assert_eq!(scope.completed("delete"), 0, "a pending real delete has not returned");
|
||||
drop(admission);
|
||||
deletion.await.expect("actual exact-source root deletion returns");
|
||||
assert_eq!(scope.completed("delete"), 1);
|
||||
assert_eq!(scope.completed("delete_paths"), 1);
|
||||
assert!(!source.exists(), "the receipt follows the real destructive operation");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn slow_disk_operation_warns_once_while_pending_and_preserves_result() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
@@ -3840,6 +4146,166 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_multipart_recovery_waiter_preserves_physical_owner(wait_timeout: Duration) {
|
||||
use crate::disk::multipart_source::{
|
||||
MULTIPART_SOURCE_PROTOCOL_VERSION, MultipartRecoveryRound, MultipartSourceOperation, MultipartSourcePhase,
|
||||
MultipartSourceRequest, MultipartSourceResponse, SourceGeneration,
|
||||
};
|
||||
use crate::disk::{RUSTFS_META_MULTIPART_BUCKET, os};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use rustfs_concurrency::adaptive::AdaptiveAdmission;
|
||||
use rustfs_filemeta::FileMeta;
|
||||
use std::num::NonZeroU16;
|
||||
|
||||
runtime_sources::ensure_test_rpc_secret();
|
||||
let root = tempfile::tempdir().expect("formatted MPU disk root");
|
||||
let meta_dir = root.path().join(RUSTFS_META_BUCKET);
|
||||
std::fs::create_dir_all(&meta_dir).expect("format directory");
|
||||
let mut format = FormatV3::new(1, 4);
|
||||
format.erasure.this = format.erasure.sets[0][0];
|
||||
std::fs::write(meta_dir.join(FORMAT_CONFIG_FILE), format.to_json().expect("native format encoding"))
|
||||
.expect("native format file");
|
||||
let mut endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 disk root")).expect("native endpoint");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
let path = format!("upload/{}", Uuid::new_v4());
|
||||
let directory = Uuid::new_v4();
|
||||
let upload = root.path().join(RUSTFS_META_MULTIPART_BUCKET).join(&path);
|
||||
std::fs::create_dir_all(upload.join(directory.to_string())).expect("real original shard directory");
|
||||
std::fs::write(upload.join(directory.to_string()).join("part.1"), b"original shard").expect("real shard");
|
||||
let mut original = FileInfo::new("object", 2, 2);
|
||||
original.erasure.distribution = vec![1, 2, 3, 4];
|
||||
original.erasure.index = 1;
|
||||
original.data_dir = Some(directory);
|
||||
original.mod_time = Some(::time::OffsetDateTime::now_utc());
|
||||
rustfs_utils::http::metadata_compat::insert_str(&mut original.metadata, "multipart-bucket", "bucket".to_owned());
|
||||
rustfs_utils::http::metadata_compat::insert_str(&mut original.metadata, "multipart-object", "object".to_owned());
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(original).expect("original native FI");
|
||||
let head = upload.join("xl.meta");
|
||||
let before = meta.marshal_msg().expect("original head bytes");
|
||||
std::fs::write(&head, &before).expect("actual original native head");
|
||||
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("formatted native disk"));
|
||||
let wrapper = Arc::new(LocalDiskWrapper::new(Arc::clone(&disk), false));
|
||||
let inspect = || MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: path.clone(),
|
||||
operation: MultipartSourceOperation::Inspect { lineage: None },
|
||||
};
|
||||
let MultipartSourceResponse::Inspected(initial) =
|
||||
wrapper.recover_multipart_source(inspect()).await.expect("initial source")
|
||||
else {
|
||||
panic!("native inspection response");
|
||||
};
|
||||
let expected = initial.snapshot.expect("initial active head");
|
||||
assert_eq!(expected.phase, MultipartSourcePhase::Active);
|
||||
let generation = SourceGeneration {
|
||||
epoch: expected.generation.epoch + 1,
|
||||
nonce: Uuid::new_v4(),
|
||||
};
|
||||
let request = MultipartSourceRequest {
|
||||
protocol_version: MULTIPART_SOURCE_PROTOCOL_VERSION,
|
||||
path: path.clone(),
|
||||
operation: MultipartSourceOperation::Claim {
|
||||
round: MultipartRecoveryRound {
|
||||
lineage: expected.lineage.clone(),
|
||||
from_generation: expected.generation,
|
||||
old_attempt: Uuid::new_v4(),
|
||||
generation,
|
||||
round: Uuid::new_v4(),
|
||||
},
|
||||
expected,
|
||||
},
|
||||
};
|
||||
let held_namespace = os::acquire_namespace_mutation_lease_with_owner(
|
||||
&disk
|
||||
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &path)
|
||||
.expect("canonical native lease path"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let owner: Arc<dyn Send + Sync> = ctx.begin_namespace_commit();
|
||||
let resources = AdaptiveAdmission::new(NonZeroU16::new(1).expect("one resource unit"), 1);
|
||||
let unit = resources.acquire().await;
|
||||
let operation = wrapper.clone();
|
||||
time::pause();
|
||||
let mut waiter = tokio::spawn(crate::disk::with_background_resource_unit(unit, async move {
|
||||
operation
|
||||
.recover_multipart_source_owned(request, Some(owner), wait_timeout)
|
||||
.await
|
||||
}));
|
||||
while wrapper.health.waiting_count() == 0 {
|
||||
assert!(!waiter.is_finished(), "recovery must enter the real physical operation");
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert!(futures::poll!(&mut waiter).is_pending());
|
||||
assert!(ctx.namespace_commits_pending());
|
||||
assert_eq!(resources.snapshot().active, 1);
|
||||
assert_eq!(std::fs::read(&head).expect("head before timeout"), before);
|
||||
assert!(!upload.join(".mpu-source-state").exists());
|
||||
time::advance(if wait_timeout.is_zero() {
|
||||
Duration::from_secs(31)
|
||||
} else {
|
||||
wait_timeout
|
||||
})
|
||||
.await;
|
||||
if wait_timeout.is_zero() {
|
||||
assert!(futures::poll!(&mut waiter).is_pending(), "zero keeps the configured no-timeout policy");
|
||||
} else {
|
||||
assert!(matches!((&mut waiter).await.expect("logical waiter"), Err(DiskError::Timeout)));
|
||||
}
|
||||
assert!(ctx.namespace_commits_pending(), "a logical timeout cannot drain the instance owner");
|
||||
assert_eq!(resources.snapshot().active, 1, "the physical task must retain its resource unit");
|
||||
assert_eq!(wrapper.health.waiting_count(), 1, "the native operation must remain active");
|
||||
assert_eq!(std::fs::read(&head).expect("head at logical deadline"), before);
|
||||
assert!(!upload.join(".mpu-source-state").exists());
|
||||
|
||||
drop(held_namespace);
|
||||
time::resume();
|
||||
if wait_timeout.is_zero() {
|
||||
assert!(matches!(
|
||||
waiter.await.expect("unbounded configured waiter"),
|
||||
Ok(MultipartSourceResponse::Claimed(_))
|
||||
));
|
||||
}
|
||||
time::timeout(Duration::from_secs(10), async {
|
||||
while ctx.namespace_commits_pending() || resources.snapshot().active != 0 || wrapper.health.waiting_count() != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("actual recovery tail should drain after the real namespace lease is released");
|
||||
let MultipartSourceResponse::Inspected(completed) = wrapper
|
||||
.recover_multipart_source(inspect())
|
||||
.await
|
||||
.expect("completed native source")
|
||||
else {
|
||||
panic!("native inspection response");
|
||||
};
|
||||
let completed = completed.snapshot.expect("durable recovered head");
|
||||
assert_eq!(completed.phase, MultipartSourcePhase::Recovering);
|
||||
assert_eq!(completed.generation, generation);
|
||||
assert_ne!(std::fs::read(&head).expect("completed source head"), before);
|
||||
assert!(upload.join(".mpu-source-state").is_file());
|
||||
assert_eq!(
|
||||
std::fs::read(upload.join(directory.to_string()).join("part.1")).expect("preserved shard"),
|
||||
b"original shard"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_recovery_timeout_keeps_native_claim_and_instance_owner_alive() {
|
||||
assert_multipart_recovery_waiter_preserves_physical_owner(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_recovery_zero_wait_budget_keeps_the_existing_no_timeout_policy() {
|
||||
assert_multipart_recovery_waiter_preserves_physical_owner(Duration::ZERO).await;
|
||||
}
|
||||
|
||||
struct PendingWriter;
|
||||
|
||||
#[test]
|
||||
@@ -4240,6 +4706,38 @@ mod tests {
|
||||
assert_eq!(snapshot.total_errors_availability, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_online_status_matches_io_health_and_disk_identity() {
|
||||
let dir = tempfile::tempdir().expect("create a drive root");
|
||||
let mut endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 root")).expect("local endpoint");
|
||||
endpoint.set_pool_index(0);
|
||||
endpoint.set_set_index(0);
|
||||
endpoint.set_disk_index(0);
|
||||
let meta_dir = dir.path().join(RUSTFS_META_BUCKET);
|
||||
tokio::fs::create_dir_all(&meta_dir).await.expect("create metadata directory");
|
||||
let mut format = FormatV3::new(1, 1);
|
||||
format.erasure.this = format.erasure.sets[0][0];
|
||||
tokio::fs::write(meta_dir.join(FORMAT_CONFIG_FILE), format.to_json().expect("serialize format"))
|
||||
.await
|
||||
.expect("persist original identity");
|
||||
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("open formatted drive"));
|
||||
let wrapper = LocalDiskWrapper::new(disk, false);
|
||||
assert!(wrapper.is_online().await, "healthy formatted drive is online");
|
||||
wrapper.health.force_runtime_state_for_test(RuntimeDriveHealthState::Suspect);
|
||||
assert!(wrapper.is_online().await, "suspect drives still admit foreground I/O");
|
||||
wrapper.health.force_runtime_state_for_test(RuntimeDriveHealthState::Offline);
|
||||
assert!(!wrapper.is_online().await, "a readable format cannot make a faulty handle usable");
|
||||
assert!(!wrapper.health.mark_recovery_success(&endpoint, "test_probe"));
|
||||
assert_eq!(wrapper.runtime_state(), RuntimeDriveHealthState::Returning);
|
||||
assert!(
|
||||
!wrapper.is_online().await,
|
||||
"returning drives retain the faulty I/O gate until recovery completes"
|
||||
);
|
||||
wrapper.reset_health_for_store_init_retry();
|
||||
wrapper.set_disk_id_state(Some(Uuid::new_v4())).await;
|
||||
assert!(!wrapper.is_online().await, "healthy state must not bypass a mismatched disk identity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_capacity_keeps_disk_identity_precheck_without_requiring_id_in_snapshot() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
|
||||
@@ -117,9 +117,6 @@ pub const O_APPEND: FileMode = 0x00400;
|
||||
// create_new: bool,
|
||||
|
||||
pub async fn open_file(path: impl AsRef<Path>, mode: FileMode) -> io::Result<File> {
|
||||
if mode & O_CREATE != 0 {
|
||||
super::os::record_native_publication(path.as_ref());
|
||||
}
|
||||
let base_opts = match mode & (O_RDONLY | O_WRONLY | O_RDWR) {
|
||||
O_RDONLY => get_readonly_options(),
|
||||
O_WRONLY => get_writeonly_options(),
|
||||
@@ -162,9 +159,18 @@ pub fn lstat_std(path: impl AsRef<Path>) -> io::Result<Metadata> {
|
||||
std::fs::symlink_metadata(path)
|
||||
}
|
||||
|
||||
pub fn create_dir_all_std(path: &Path) -> io::Result<()> {
|
||||
match std::fs::create_dir(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => std::fs::create_dir_all(path),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn make_dir_all(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
super::os::record_native_publication(path.as_ref());
|
||||
fs::create_dir_all(path.as_ref()).await
|
||||
let path = path.as_ref().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || create_dir_all_std(&path)).await?
|
||||
}
|
||||
|
||||
fn is_dir_error(e: &io::Error) -> bool {
|
||||
@@ -214,19 +220,17 @@ pub fn remove_all_std(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn mkdir(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
super::os::record_native_publication(path.as_ref());
|
||||
fs::create_dir(path.as_ref()).await
|
||||
}
|
||||
|
||||
pub async fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
|
||||
super::os::record_native_publication(to.as_ref());
|
||||
fs::rename(from, to).await
|
||||
}
|
||||
|
||||
#[cfg(any(not(windows), test))]
|
||||
pub fn rename_std(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
|
||||
super::os::record_native_publication(to.as_ref());
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
@@ -246,7 +250,7 @@ impl BucketExistenceCache {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_exists(&self, path: &PathBuf) -> Option<bool> {
|
||||
fn check_exists(&self, path: &Path) -> Option<bool> {
|
||||
let mut cache = self.cache.lock().ok()?;
|
||||
if let Some((timestamp, exists)) = cache.get(path) {
|
||||
if timestamp.elapsed() < self.ttl {
|
||||
@@ -263,7 +267,7 @@ impl BucketExistenceCache {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate(&self, path: &PathBuf) {
|
||||
fn invalidate(&self, path: &Path) {
|
||||
if let Ok(mut cache) = self.cache.lock() {
|
||||
cache.remove(path);
|
||||
}
|
||||
@@ -275,15 +279,16 @@ static BUCKET_EXISTENCE_CACHE: std::sync::LazyLock<BucketExistenceCache> =
|
||||
|
||||
/// Cached access check - reduces statx syscalls
|
||||
pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
let path_buf = path.as_ref().to_path_buf();
|
||||
let path = path.as_ref();
|
||||
|
||||
if let Some(exists) = BUCKET_EXISTENCE_CACHE.check_exists(&path_buf) {
|
||||
if let Some(exists) = BUCKET_EXISTENCE_CACHE.check_exists(path) {
|
||||
if exists {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "bucket not found (cached)"));
|
||||
}
|
||||
|
||||
let path_buf = path.to_path_buf();
|
||||
let result = fs::metadata(&path_buf).await;
|
||||
|
||||
match &result {
|
||||
@@ -299,7 +304,7 @@ pub async fn cached_access(path: impl AsRef<Path>) -> io::Result<()> {
|
||||
}
|
||||
|
||||
pub fn invalidate_bucket_cache(path: impl AsRef<Path>) {
|
||||
BUCKET_EXISTENCE_CACHE.invalidate(&path.as_ref().to_path_buf());
|
||||
BUCKET_EXISTENCE_CACHE.invalidate(path.as_ref());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+1130
-1040
File diff suppressed because it is too large
Load Diff
@@ -281,10 +281,12 @@ type PreparedInlineMetadata = (
|
||||
Option<StagedRollbackBackup>,
|
||||
bool,
|
||||
os::PreparedRenameSource,
|
||||
Option<Arc<os::PreparedPoolMetaBackup>>,
|
||||
Option<super::pool_meta_native::DurableHeadReceipt>,
|
||||
);
|
||||
|
||||
enum InlinePreparationOutcome {
|
||||
Committed(RenameDataResp),
|
||||
Committed(RenameDataResp, Option<super::pool_meta_native::DurableHeadReceipt>),
|
||||
CommitErr(DiskError),
|
||||
Prepared(PreparedInlineMetadata),
|
||||
}
|
||||
@@ -400,6 +402,14 @@ pub(super) struct RenameDataState {
|
||||
preflight_rejection: Option<LocalRenamePreflightRejection>,
|
||||
}
|
||||
|
||||
// Keep optional native MPU state machines out of ordinary rename frames.
|
||||
// Construct the future here as well as pinning it, so its temporary stays off
|
||||
// the caller's poll stack. Call only from the existing native MPU branches.
|
||||
#[inline(never)]
|
||||
fn pin_multipart_future<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
|
||||
Box::pin(build())
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
#[tracing::instrument(name = "rename_data", target = "rustfs_ecstore::disk::local", level = "trace", skip_all)]
|
||||
pub(super) async fn rename_data_inner(
|
||||
@@ -411,12 +421,54 @@ impl LocalDisk {
|
||||
dst_path: &str,
|
||||
state: &mut RenameDataState,
|
||||
) -> Result<RenameDataResp> {
|
||||
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), src_volume, src_path).inspect_err(
|
||||
|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
},
|
||||
)?;
|
||||
super::multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), dst_volume, dst_path).inspect_err(
|
||||
|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
},
|
||||
)?;
|
||||
super::pool_meta_native::reject_raw_path(&self.root, src_volume, src_path).inspect_err(|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?;
|
||||
super::pool_meta_native::reject_noncanonical_object(&self.root, dst_volume, dst_path).inspect_err(|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?;
|
||||
crate::hp_guard!("LocalDisk::rename_data");
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
if fi.is_healing() {
|
||||
wait_for_heal_commit_test_barrier(&self.root, dst_volume, dst_path).await?;
|
||||
}
|
||||
let mut fi = fi;
|
||||
let pool_meta_condition = if super::pool_meta_native::is_object(&self.root, dst_volume, dst_path) {
|
||||
Some(super::pool_meta_native::PoolMetaNativeCasCondition::take(&mut fi).inspect_err(|_| {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let native_timing = pool_meta_condition.is_some();
|
||||
if pool_meta_condition
|
||||
.as_ref()
|
||||
.is_some_and(|condition| fi.data_dir == Some(condition.attempt_id))
|
||||
{
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if let Some(condition) = &pool_meta_condition {
|
||||
let new_identity = super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?;
|
||||
if condition
|
||||
.parent
|
||||
.as_ref()
|
||||
.is_some_and(|parent| new_identity.mod_time_unix_nanos <= parent.mod_time_unix_nanos)
|
||||
{
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
}
|
||||
// A non-force DeleteBucket must not remove a directory while a local
|
||||
// object commit is publishing into it. The peer's empty scan remains
|
||||
// optimistic; this lease establishes the local commit/delete order and
|
||||
@@ -442,8 +494,32 @@ impl LocalDisk {
|
||||
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
|
||||
None => None,
|
||||
};
|
||||
// A failed fanout can undo the destination after this disk succeeded.
|
||||
// Keep the original upload authoritative until coordinator quorum: only
|
||||
// this private attempt's links and generated metadata may be consumed.
|
||||
let multipart_staging = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
Some(
|
||||
pin_multipart_future(|| {
|
||||
self.prepare_multipart_commit_staging(src_path, &fi, dst_volume, state.namespace_owner.clone())
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let multipart_publication = if let Some(staging) = multipart_staging.as_ref() {
|
||||
Some(pin_multipart_future(|| self.prepare_native_multipart_publication(src_path, &mut fi, staging)).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (src_volume, src_path) = match multipart_staging.as_ref() {
|
||||
Some(staging) => (super::super::RUSTFS_META_TMP_BUCKET, staging.path.as_str()),
|
||||
None => (src_volume, src_path),
|
||||
};
|
||||
// Quota admission -> metadata RMW -> namespace/volume publication.
|
||||
let no_inline = fi.data.is_none() && fi.size > 0;
|
||||
// MPU completion remains parts-backed even for a zero-byte part; its
|
||||
// version-owned retirement anchor is published with that same DD.
|
||||
let no_inline = fi.data.is_none() && (fi.size > 0 || multipart_publication.is_some());
|
||||
let fi_healing = fi.is_healing();
|
||||
let staging_prefix = if fi_healing && !no_inline && src_volume == super::super::RUSTFS_META_TMP_BUCKET {
|
||||
Some(tmp_staging_mutation_path(&self.root, src_path)?)
|
||||
@@ -455,19 +531,46 @@ impl LocalDisk {
|
||||
.strip_prefix(self.io_root())
|
||||
.map_err(|_| DiskError::InvalidPath)?,
|
||||
);
|
||||
let metadata_lease =
|
||||
os::acquire_metadata_mutation_lease(&root_destination_object_path, state.namespace_owner.take()).await;
|
||||
let metadata_wait = os::StageTimer::new(native_timing, "pool_meta_native_metadata_lease_wait");
|
||||
let metadata_owner: Option<Arc<dyn Send + Sync>> = match multipart_staging.as_ref() {
|
||||
Some(staging) => Some(Arc::new((state.namespace_owner.take(), staging.lease.clone()))),
|
||||
None => state.namespace_owner.take(),
|
||||
};
|
||||
let metadata_lease = os::acquire_metadata_mutation_lease(&root_destination_object_path, metadata_owner).await;
|
||||
drop(metadata_wait);
|
||||
#[cfg(unix)]
|
||||
let parent_receipt = if native_timing {
|
||||
self.pool_meta_durable_head.lock().take().filter(|receipt| {
|
||||
pool_meta_condition.as_ref().is_some_and(|condition| {
|
||||
condition.parent_raw_sha256.is_none() && condition.parent.as_ref() == Some(&receipt.identity)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let mut receipt_mutation = native_timing
|
||||
.then(|| super::pool_meta_native::ReceiptMutationGuard::new(&self.pool_meta_durable_head, metadata_lease.clone()));
|
||||
let namespace_wait = os::StageTimer::new(native_timing, "pool_meta_native_namespace_lease_wait");
|
||||
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
|
||||
&self.root,
|
||||
dst_volume,
|
||||
&destination_object_path,
|
||||
&root_destination_object_path,
|
||||
staging_prefix.as_deref(),
|
||||
Some(metadata_lease),
|
||||
)
|
||||
.await;
|
||||
drop(namespace_wait);
|
||||
if let Some(claim) = quota_fence_claim {
|
||||
mutation_lease.attach_external_guard(claim);
|
||||
}
|
||||
if let Some(condition) = &pool_meta_condition {
|
||||
let _parent_check = os::StageTimer::new(native_timing, "pool_meta_native_parent_check");
|
||||
if let Err(err) = self.check_pool_meta_parent(condition, &destination_object_path).await {
|
||||
state.preflight_rejection = Some(LocalRenamePreflightRejection(()));
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
if fi.is_legacy_indexed_delete_marker() {
|
||||
fi.erasure.index = 0;
|
||||
}
|
||||
@@ -524,24 +627,23 @@ impl LocalDisk {
|
||||
return Err(to_access_error(e, DiskError::VolumeAccessDenied).into());
|
||||
}
|
||||
|
||||
// xl.meta path
|
||||
let src_file_path = self.io_get_object_path(src_volume, format!("{}/{}", src_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
// Native publication consumes its canonical head. The legacy path
|
||||
// keeps main's independent recovery authority and staging filename.
|
||||
let preserve_upload_metadata =
|
||||
multipart_publication.is_none() && src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET;
|
||||
let source_metadata_name = if preserve_upload_metadata {
|
||||
super::super::MULTIPART_COMMIT_METADATA_FILE
|
||||
} else {
|
||||
STORAGE_FORMAT_FILE
|
||||
};
|
||||
let src_file_path = match multipart_publication.as_ref() {
|
||||
Some(publication) => publication.canonical_head(),
|
||||
None => self.io_get_object_path(src_volume, format!("{src_path}/{source_metadata_name}").as_str())?,
|
||||
};
|
||||
let dst_file_path = self.io_get_object_open_path(dst_volume, format!("{}/{}", dst_path, STORAGE_FORMAT_FILE).as_str())?;
|
||||
|
||||
// Multipart restore completion has validated staged local parts,
|
||||
// while its public status stays pending until finalization.
|
||||
let pending_multipart_restore = src_volume == crate::disk::RUSTFS_META_MULTIPART_BUCKET
|
||||
&& fi
|
||||
.metadata
|
||||
.get(rustfs_filemeta::metadata_keys::RESTORE)
|
||||
.and_then(|value| rustfs_filemeta::parse_restore_obj_status(value).ok())
|
||||
.is_some_and(|status| status.is_restore_in_progress == Some(true));
|
||||
if pending_multipart_restore && fi.data_dir.is_none_or(|dir| dir.is_nil()) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
|
||||
// data_dir path (only used by the non-inline commit branch)
|
||||
let has_data_dir_path = if no_inline && (!fi.is_remote() || pending_multipart_restore) {
|
||||
let has_data_dir_path = if no_inline && !fi.is_remote() {
|
||||
if let Some(data_dir) = fi
|
||||
.data_dir
|
||||
.map(|dir| rustfs_utils::path::retain_slash(dir.to_string().as_str()))
|
||||
@@ -563,6 +665,22 @@ impl LocalDisk {
|
||||
None
|
||||
};
|
||||
|
||||
if multipart_publication.is_none()
|
||||
&& let Some((source_directory, target_directory)) = has_data_dir_path.as_ref()
|
||||
{
|
||||
// Reject imported provenance before any DD purge or publication.
|
||||
// Same-disk heal copies only an already verified local anchor.
|
||||
self.retain_owned_multipart_anchor(
|
||||
&fi,
|
||||
dst_volume,
|
||||
dst_path,
|
||||
source_directory,
|
||||
target_directory,
|
||||
mutation_lease.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
check_path_length(src_file_path.to_string_lossy().to_string().as_str())?;
|
||||
check_path_length(dst_file_path.to_string_lossy().to_string().as_str())?;
|
||||
|
||||
@@ -582,8 +700,10 @@ impl LocalDisk {
|
||||
.ok_or_else(|| DiskError::other("missing object metadata parent"))?;
|
||||
// Acquire the common trees before reading destination metadata. On
|
||||
// Windows this pins the object directory identity across publication.
|
||||
// Multipart authority already created its source tree. Recreating it
|
||||
// here would resurrect staging retired before a delayed request arrived.
|
||||
let (rename_commit_guard, has_dst_buf) = prepare_rename_commit_and_destination_metadata(
|
||||
!no_inline,
|
||||
!no_inline && !preserve_upload_metadata,
|
||||
src_file_parent,
|
||||
dst_file_parent,
|
||||
&dst_file_path,
|
||||
@@ -602,6 +722,39 @@ impl LocalDisk {
|
||||
data_paths: has_data_dir_path.as_ref(),
|
||||
};
|
||||
|
||||
if preserve_upload_metadata {
|
||||
let source_object_path = self.get_object_path(src_volume, src_path)?;
|
||||
let authority_owner: Arc<dyn Send + Sync> = if source_object_path == root_destination_object_path {
|
||||
commit.mutation_lease.clone()
|
||||
} else {
|
||||
os::acquire_metadata_mutation_lease(&source_object_path, Some(commit.mutation_lease.clone())).await
|
||||
};
|
||||
let authority = self.io_get_object_path(src_volume, &format!("{src_path}/{STORAGE_FORMAT_FILE}"))?;
|
||||
let recovery = self.io_get_object_path(
|
||||
src_volume,
|
||||
&format!("{src_path}/{}/{STORAGE_FORMAT_FILE}", super::super::MULTIPART_RECOVERY_DIR),
|
||||
)?;
|
||||
check_path_length(recovery.to_string_lossy().as_ref())?;
|
||||
os::rename_all_with_owner(
|
||||
&authority,
|
||||
&recovery,
|
||||
src_file_parent,
|
||||
commit.publication_root,
|
||||
Some(authority_owner.clone()),
|
||||
)
|
||||
.await?;
|
||||
// This is system metadata authority, so use its existing strict
|
||||
// durability policy even when the object payload is relaxed.
|
||||
if effective_durability(src_volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(recovery.parent().ok_or(DiskError::InvalidPath)?, Some(authority_owner.clone()))
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
os::fsync_dir_with_owner(src_file_parent, Some(authority_owner.clone()))
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
if no_inline {
|
||||
// Non-inline: read xl.meta, parse, write, rename data dir, rename xl.meta
|
||||
let mut xlmeta = FileMeta::new();
|
||||
@@ -631,13 +784,17 @@ impl LocalDisk {
|
||||
let (old_current_size, old_current_source_checked, old_current_source) =
|
||||
observe_old_current(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 rollback_data_dir = has_old_data_dir.or_else(|| {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
|
||||
condition.has_parent().then_some(condition.attempt_id)
|
||||
} else {
|
||||
has_old_data_dir.or_else(|| {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(old_data_dir) = has_old_data_dir.as_ref() {
|
||||
let _ = xlmeta.data.remove_two(version_id, *old_data_dir);
|
||||
}
|
||||
@@ -651,7 +808,10 @@ impl LocalDisk {
|
||||
// after the commit rename, and a crash before the rename means the
|
||||
// PUT was never acknowledged. A metadata commit: relaxed tiers
|
||||
// leave it to the page cache.
|
||||
let tmp_meta_sync = if durability.syncs_commit_metadata() {
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.prepare_metadata(&new_dst_buf, has_dst_buf.as_deref())).await?;
|
||||
}
|
||||
let tmp_meta_sync = if durability.syncs_commit_metadata() || multipart_publication.is_some() {
|
||||
SyncMode::FileOnly
|
||||
} else {
|
||||
SyncMode::None
|
||||
@@ -671,7 +831,9 @@ impl LocalDisk {
|
||||
// rename below to report through the existing rollback path. Payload
|
||||
// durability is kept by both strict and relaxed.
|
||||
let tmp_meta_write = {
|
||||
let src_file_path = commit.source_metadata.to_path_buf();
|
||||
let src_file_path = multipart_publication
|
||||
.as_ref()
|
||||
.map_or_else(|| commit.source_metadata.to_path_buf(), |publication| publication.prepared_path.clone());
|
||||
let dst_file_path = dst_file_path.clone();
|
||||
let owned_directory_guard = commit.directory_guard.clone();
|
||||
let owned_mutation_lease = commit.mutation_lease.clone();
|
||||
@@ -708,6 +870,10 @@ impl LocalDisk {
|
||||
// sequential version did.
|
||||
let prepared_metadata_source = tmp_meta_res?;
|
||||
shard_sync_res?;
|
||||
let prepared_metadata_source = match multipart_publication.as_ref() {
|
||||
Some(publication) => pin_multipart_future(|| publication.install_head(prepared_metadata_source)).await?,
|
||||
None => prepared_metadata_source,
|
||||
};
|
||||
commit.directory_guard = remove_dst_base_before_commit(
|
||||
dst_path,
|
||||
commit.directory_guard,
|
||||
@@ -746,30 +912,49 @@ impl LocalDisk {
|
||||
"Healing commit could not purge the stale destination data dir"
|
||||
);
|
||||
}
|
||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
|
||||
&& let Err(err) = os::rename_all_with_commit_guard(
|
||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref() {
|
||||
#[cfg(target_os = "linux")]
|
||||
let payload_guard = if multipart_publication.is_some() {
|
||||
Some(
|
||||
os::multipart_payload_commit_guard(
|
||||
&commit.directory_guard,
|
||||
src_data_path.parent().ok_or(DiskError::InvalidPath)?,
|
||||
commit.mutation_lease.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(to_file_error)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
let directory_guard = payload_guard.as_ref().unwrap_or(&commit.directory_guard);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let directory_guard = &commit.directory_guard;
|
||||
if let Err(err) = os::rename_all_with_commit_guard(
|
||||
src_data_path,
|
||||
dst_data_path,
|
||||
&skip_parent,
|
||||
commit.publication_root,
|
||||
&commit.directory_guard,
|
||||
directory_guard,
|
||||
commit.mutation_lease.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
target: "rustfs_ecstore::disk::local",
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "rename_all_data_path_failed",
|
||||
src_path = ?src_data_path,
|
||||
dst_path = ?dst_data_path,
|
||||
error = ?err,
|
||||
"Disk local rename flow failed"
|
||||
);
|
||||
commit.restore_published_data_source().await?;
|
||||
return Err(err);
|
||||
{
|
||||
info!(
|
||||
target: "rustfs_ecstore::disk::local",
|
||||
event = EVENT_DISK_LOCAL_RENAME_REJECTED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_DISK_LOCAL,
|
||||
reason = "rename_all_data_path_failed",
|
||||
src_path = ?src_data_path,
|
||||
dst_path = ?dst_data_path,
|
||||
error = ?err,
|
||||
"Disk local rename flow failed"
|
||||
);
|
||||
commit.restore_published_data_source().await?;
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
if has_data_dir_path.is_some() {
|
||||
@@ -807,7 +992,9 @@ impl LocalDisk {
|
||||
} else {
|
||||
SyncMode::None
|
||||
};
|
||||
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref()) {
|
||||
if let (Some(old_data_dir), Some(dst_buf)) = (rollback_data_dir, has_dst_buf.as_ref())
|
||||
&& multipart_publication.as_ref().is_none_or(|publication| publication.legacy)
|
||||
{
|
||||
let backup_parent = dst_file_parent.join(old_data_dir.to_string());
|
||||
#[cfg(not(windows))]
|
||||
if let Err(err) = os::make_dir_all(&backup_parent, &skip_parent).await {
|
||||
@@ -899,7 +1086,11 @@ impl LocalDisk {
|
||||
return Err(err);
|
||||
}
|
||||
if should_fail_after_metadata_commit(dst_path) {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
|
||||
} else {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
}
|
||||
return Err(DiskError::Unexpected);
|
||||
}
|
||||
|
||||
@@ -926,7 +1117,11 @@ impl LocalDisk {
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_DST_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
|
||||
} else {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
}
|
||||
// The commit rename changed the dst part inodes before this fsync
|
||||
// failed and rolled them back; drop any fd cached during that
|
||||
// window so readers re-open the restored inode (rustfs/backlog#1177).
|
||||
@@ -963,7 +1158,12 @@ impl LocalDisk {
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_ANCESTOR_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
if let Some(publication) = multipart_publication.as_ref() {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone()))
|
||||
.await?;
|
||||
} else {
|
||||
commit.rollback_non_inline_std(rollback_data_dir).map_err(to_file_error)?;
|
||||
}
|
||||
// Same post-commit rollback window as above — drop cached
|
||||
// dst part fds so readers re-open the restored inode
|
||||
// (rustfs/backlog#1177).
|
||||
@@ -989,14 +1189,11 @@ impl LocalDisk {
|
||||
#[cfg(windows)]
|
||||
drop(commit.directory_guard);
|
||||
|
||||
if let Some(src_file_path_parent) = src_file_path.parent() {
|
||||
if src_volume != super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
let _ = std::fs::remove_dir(src_file_path_parent);
|
||||
} else {
|
||||
let _ = self
|
||||
.delete_file(&dst_volume_dir, &src_file_path_parent.to_path_buf(), true, false)
|
||||
.await;
|
||||
}
|
||||
if multipart_publication.is_none()
|
||||
&& !preserve_upload_metadata
|
||||
&& let Some(parent) = src_file_path.parent()
|
||||
{
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
// Heal reuses a version's `data_dir` and lands the rebuilt shard on
|
||||
@@ -1014,9 +1211,24 @@ impl LocalDisk {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
|
||||
let multipart_source = match multipart_publication.as_ref() {
|
||||
Some(publication) => match pin_multipart_future(|| publication.finish(commit.mutation_lease.clone())).await {
|
||||
Ok(result) => Some(Box::new(result)),
|
||||
Err(error) => {
|
||||
pin_multipart_future(|| publication.restore_target_after_error(commit.mutation_lease.clone())).await?;
|
||||
return Err(error);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Ok(RenameDataResp {
|
||||
multipart_source,
|
||||
old_data_dir: has_old_data_dir,
|
||||
rollback_data_dir,
|
||||
rollback_data_dir: if multipart_publication.as_ref().is_some_and(|publication| !publication.legacy) {
|
||||
None
|
||||
} else {
|
||||
rollback_data_dir
|
||||
},
|
||||
cleanup_data_dir: has_old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
@@ -1029,25 +1241,47 @@ impl LocalDisk {
|
||||
// allowing a retry to reuse the deterministic staging path too early.
|
||||
let src = src_file_path.clone();
|
||||
let dst = dst_file_path.clone();
|
||||
let cleanup_path = if src_volume == super::super::RUSTFS_META_MULTIPART_BUCKET {
|
||||
src_file_path.parent().map(|p| p.to_path_buf())
|
||||
} else {
|
||||
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();
|
||||
let dst_volume_dir_for_commit = dst_volume_dir.clone();
|
||||
let publication_root_for_commit = self.publication_root.clone();
|
||||
#[cfg(not(windows))]
|
||||
let cleanup_src_parent = cleanup_path.is_none();
|
||||
let cleanup_src_parent = multipart_publication.is_none() && !preserve_upload_metadata;
|
||||
#[cfg(windows)]
|
||||
let source_parent = src_file_parent.to_path_buf();
|
||||
let rename_commit_guard_for_preparation = commit.directory_guard.clone();
|
||||
let sync = durability.syncs_commit_metadata();
|
||||
#[cfg(unix)]
|
||||
let receipt_identity = if sync
|
||||
&& pool_meta_condition
|
||||
.as_ref()
|
||||
.is_some_and(|condition| condition.parent_raw_sha256.is_none())
|
||||
{
|
||||
Some(super::pool_meta_native::PoolMetaNativeFileIdentity::from_file_info(&fi)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let owned_backup_path = pool_meta_condition
|
||||
.as_ref()
|
||||
.filter(|condition| condition.has_parent())
|
||||
.map(|condition| {
|
||||
destination_object_path
|
||||
.join(condition.attempt_id.to_string())
|
||||
.join(STORAGE_FORMAT_FILE_BACKUP)
|
||||
});
|
||||
#[cfg(unix)]
|
||||
if let Some(backup) = &owned_backup_path {
|
||||
let parent = backup
|
||||
.parent()
|
||||
.ok_or_else(|| DiskError::other("missing owned backup parent"))?;
|
||||
commit.mutation_lease =
|
||||
os::acquire_namespace_mutation_lease_with_owner(parent, Some(commit.mutation_lease.clone())).await;
|
||||
}
|
||||
#[cfg(test)]
|
||||
run_inline_before_file_sync_admission(dst_path);
|
||||
let mut file_sync_admission = if sync {
|
||||
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_admission");
|
||||
Some(
|
||||
os::acquire_file_sync_admission(self.file_sync_permits.clone())
|
||||
.await
|
||||
@@ -1057,7 +1291,10 @@ impl LocalDisk {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let preparation_dispatch = os::StageTimer::new(native_timing, "pool_meta_native_prepare_dispatch_wait");
|
||||
let prepare_inline_metadata = move |is_cancelled: &dyn Fn() -> bool| {
|
||||
drop(preparation_dispatch);
|
||||
let _preparation = os::StageTimer::new(native_timing, "pool_meta_native_prepare_execute");
|
||||
let mut prepared_metadata_source =
|
||||
os::create_prepared_rename_source_with_commit_guard(&src, &dst, &rename_commit_guard_for_preparation)?;
|
||||
#[cfg(windows)]
|
||||
@@ -1091,7 +1328,9 @@ impl LocalDisk {
|
||||
// Healing commits never run set-level post-return rollback; keep
|
||||
// their intra-commit rollback backup in the staging directory so
|
||||
// `<object>/<rollback_data_dir>/xl.meta.bkp` is never stranded.
|
||||
let rollback_data_dir = if fi_healing {
|
||||
let rollback_data_dir = if let Some(condition) = &pool_meta_condition {
|
||||
condition.has_parent().then_some(condition.attempt_id)
|
||||
} else if fi_healing {
|
||||
None
|
||||
} else {
|
||||
old_data_dir.or_else(|| {
|
||||
@@ -1114,13 +1353,61 @@ impl LocalDisk {
|
||||
// relaxed tiers do no per-object fsync here at all (aligned
|
||||
// with MinIO's default), trading a documented power-loss
|
||||
// window for latency.
|
||||
prepared_metadata_source.write_all(&new_buf, sync)?;
|
||||
{
|
||||
let _new_file = os::StageTimer::new(native_timing, "pool_meta_native_staged_write_sync");
|
||||
prepared_metadata_source.write_all(&new_buf, sync)?;
|
||||
}
|
||||
run_inline_preparation_before_backup(&dst_path_for_failpoint);
|
||||
#[cfg(unix)]
|
||||
let mut receipt_candidate = receipt_identity.and_then(|identity| {
|
||||
os::DurableFileReceipt::capture(&publication_root_for_commit, &src, &new_buf)
|
||||
.ok()
|
||||
.map(|physical| super::pool_meta_native::DurableHeadReceipt { physical, identity })
|
||||
});
|
||||
#[cfg(not(unix))]
|
||||
let receipt_candidate = None;
|
||||
#[cfg(unix)]
|
||||
let owned_backup = match (owned_backup_path.as_ref(), has_dst_buf.as_ref()) {
|
||||
(Some(backup_path), Some(old_metadata)) => {
|
||||
let backup = {
|
||||
let _create = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
|
||||
Arc::new(os::prepare_pool_meta_owned_backup(
|
||||
&dst,
|
||||
backup_path,
|
||||
&dst_volume_dir_for_commit,
|
||||
&publication_root_for_commit,
|
||||
old_metadata,
|
||||
should_fail_local_inline_rollback_hardlink(&dst),
|
||||
)?)
|
||||
};
|
||||
let reuses_durability = parent_receipt.as_ref().is_some_and(|receipt| {
|
||||
backup.can_reuse_durability(&receipt.physical, &publication_root_for_commit, old_metadata)
|
||||
});
|
||||
if !backup.is_linked() {
|
||||
receipt_candidate = None;
|
||||
}
|
||||
if sync && !reuses_durability {
|
||||
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
|
||||
backup.sync_data()?;
|
||||
} else if sync {
|
||||
rustfs_io_metrics::record_put_object_stage_count("pool_meta_native_backup_sync_reused", 1);
|
||||
}
|
||||
Some(backup)
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return Err(std::io::Error::new(ErrorKind::InvalidData, "pool metadata predecessor disappeared"));
|
||||
}
|
||||
(None, _) => None,
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let owned_backup = None;
|
||||
if let Some(ref old_metadata) = has_dst_buf
|
||||
&& owned_backup.is_none()
|
||||
&& (rollback_data_dir.is_some() || fi_healing || sync || cfg!(test))
|
||||
{
|
||||
#[cfg(windows)]
|
||||
let backup_path = {
|
||||
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create_sync");
|
||||
let backup_path = src
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "missing staging metadata parent"))?
|
||||
@@ -1129,9 +1416,13 @@ impl LocalDisk {
|
||||
backup_path
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let backup_path = create_local_inline_rollback_backup(&dst, &src, old_metadata)?;
|
||||
let backup_path = {
|
||||
let _backup = os::StageTimer::new(native_timing, "pool_meta_native_backup_create");
|
||||
create_local_inline_rollback_backup(&dst, &src, old_metadata)?
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
if sync {
|
||||
let _backup_sync = os::StageTimer::new(native_timing, "pool_meta_native_backup_file_sync");
|
||||
std::fs::File::open(&backup_path)?.sync_data()?;
|
||||
}
|
||||
staged_rollback_path = Some(StagedRollbackBackup(backup_path));
|
||||
@@ -1190,15 +1481,19 @@ impl LocalDisk {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(RenameDataResp {
|
||||
old_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
}));
|
||||
return Ok::<_, std::io::Error>(InlinePreparationOutcome::Committed(
|
||||
RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir: old_data_dir,
|
||||
sign: version_signature,
|
||||
old_current_size,
|
||||
old_current_source_checked,
|
||||
old_current_source,
|
||||
},
|
||||
receipt_candidate,
|
||||
));
|
||||
}
|
||||
|
||||
Ok::<_, std::io::Error>(InlinePreparationOutcome::Prepared((
|
||||
@@ -1211,8 +1506,11 @@ impl LocalDisk {
|
||||
staged_rollback_path,
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
owned_backup,
|
||||
receipt_candidate,
|
||||
)))
|
||||
};
|
||||
let preparation_total = os::StageTimer::new(native_timing, "pool_meta_native_prepare_total");
|
||||
let inline_preparation = if let Some(admission) = file_sync_admission.as_ref() {
|
||||
os::run_cancellable_blocking_namespace_file_sync_operation(
|
||||
commit.mutation_lease.clone(),
|
||||
@@ -1225,6 +1523,7 @@ impl LocalDisk {
|
||||
}
|
||||
.map_err(to_file_error)
|
||||
.map_err(DiskError::from);
|
||||
drop(preparation_total);
|
||||
let (
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir,
|
||||
@@ -1235,22 +1534,29 @@ impl LocalDisk {
|
||||
mut local_rollback_path,
|
||||
destination_was_absent,
|
||||
prepared_metadata_source,
|
||||
owned_backup,
|
||||
receipt_candidate,
|
||||
) = match inline_preparation {
|
||||
Ok(InlinePreparationOutcome::Committed(resp)) => {
|
||||
Ok(InlinePreparationOutcome::Committed(resp, receipt_candidate)) => {
|
||||
drop(file_sync_admission.take());
|
||||
#[cfg(unix)]
|
||||
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
|
||||
.await;
|
||||
#[cfg(not(unix))]
|
||||
let _ = receipt_candidate;
|
||||
#[cfg(windows)]
|
||||
drop(commit.directory_guard);
|
||||
if let Some(ref cleanup) = cleanup_path {
|
||||
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
|
||||
} else {
|
||||
#[cfg(windows)]
|
||||
if let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
for part_path in &invalidate_part_paths {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
|
||||
receipt_mutation.complete();
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Ok(InlinePreparationOutcome::CommitErr(err)) | Err(err) => {
|
||||
@@ -1284,6 +1590,42 @@ impl LocalDisk {
|
||||
return Err(DiskError::FileNotFound);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(backup) = &owned_backup {
|
||||
// The backup namespace lease owns the head transaction; retain
|
||||
// its directory and inode guards in every later detached job.
|
||||
commit.mutation_lease.attach_external_guard(backup.clone());
|
||||
#[cfg(test)]
|
||||
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
|
||||
let backup_base = dst_volume_dir.clone();
|
||||
let backup_root = commit.publication_root.clone();
|
||||
if let Some(admission) = file_sync_admission.as_ref() {
|
||||
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
let backup = backup.clone();
|
||||
let result =
|
||||
os::run_blocking_namespace_file_sync_operation(commit.mutation_lease.clone(), admission, move || {
|
||||
backup.verify_paths(&backup_base, &backup_root)?;
|
||||
backup.sync_directory()
|
||||
})
|
||||
.await;
|
||||
rustfs_io_metrics::record_put_object_stage_duration_from(
|
||||
rustfs_io_metrics::PUT_STAGE_SET_DISK_RENAME_BACKUP_DIR_FSYNC,
|
||||
fsync_started,
|
||||
);
|
||||
result.map_err(to_file_error)?;
|
||||
} else {
|
||||
let backup = backup.clone();
|
||||
os::run_blocking_namespace_operation(commit.mutation_lease.clone(), move || {
|
||||
backup.verify_paths(&backup_base, &backup_root)
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = owned_backup;
|
||||
|
||||
if let (Some(rollback_data_dir), Some(staged_backup)) =
|
||||
(rollback_data_dir, local_rollback_path.as_ref().map(StagedRollbackBackup::path))
|
||||
{
|
||||
@@ -1296,21 +1638,24 @@ impl LocalDisk {
|
||||
// rename_all acquires the backup path's namespace lease. Do not
|
||||
// hold a disk admission while acquiring another namespace lock.
|
||||
drop(file_sync_admission.take());
|
||||
if let Err(err) = os::rename_all_with_owner(
|
||||
let backup_publish = os::StageTimer::new(native_timing, "pool_meta_native_backup_publish");
|
||||
let backup_result = os::rename_all_with_owner(
|
||||
staged_backup,
|
||||
&backup_path,
|
||||
&dst_volume_dir,
|
||||
commit.publication_root,
|
||||
Some(commit.mutation_lease.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
drop(backup_publish);
|
||||
if let Err(err) = backup_result {
|
||||
let _ = remove_file_if_exists(staged_backup);
|
||||
return Err(err);
|
||||
}
|
||||
#[cfg(test)]
|
||||
run_rename_data_after_first_publication(&self.root, dst_volume, dst_path);
|
||||
if sync {
|
||||
let _admission = os::StageTimer::new(native_timing, "pool_meta_native_file_sync_readmission");
|
||||
file_sync_admission = Some(
|
||||
os::acquire_file_sync_admission(self.file_sync_permits.clone())
|
||||
.await
|
||||
@@ -1321,6 +1666,7 @@ impl LocalDisk {
|
||||
if let Some(admission) = file_sync_admission.as_ref()
|
||||
&& let Some(backup_parent) = backup_path.parent()
|
||||
{
|
||||
let _backup_dir = os::StageTimer::new(native_timing, "pool_meta_native_backup_dir_sync");
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) =
|
||||
os::fsync_dir_with_namespace_file_sync_limit(backup_parent, commit.mutation_lease.clone(), admission)
|
||||
@@ -1340,6 +1686,7 @@ impl LocalDisk {
|
||||
local_rollback_path = None;
|
||||
}
|
||||
|
||||
let head_publish = os::StageTimer::new(native_timing, "pool_meta_native_head_publish");
|
||||
let commit_result = if should_fail_commit_rename(dst_path) {
|
||||
Err(DiskError::other("test fail during metadata commit rename"))
|
||||
} else {
|
||||
@@ -1354,6 +1701,7 @@ impl LocalDisk {
|
||||
)
|
||||
.await
|
||||
};
|
||||
drop(head_publish);
|
||||
if let Err(err) = commit_result {
|
||||
if let Some(backup_path) = local_rollback_path.as_ref().map(StagedRollbackBackup::path) {
|
||||
let _ = remove_file_if_exists(backup_path);
|
||||
@@ -1374,6 +1722,7 @@ impl LocalDisk {
|
||||
if let Some(admission) = file_sync_admission.as_ref()
|
||||
&& let Some(dst_parent) = dst_file_path.parent()
|
||||
{
|
||||
let _head_dir = os::StageTimer::new(native_timing, "pool_meta_native_head_dir_sync");
|
||||
let fsync_started = rustfs_io_metrics::put_stage_timer();
|
||||
if let Err(err) = os::fsync_commit_directory(
|
||||
dst_parent,
|
||||
@@ -1454,6 +1803,12 @@ impl LocalDisk {
|
||||
return Err(DiskError::from(err));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
self.publish_pool_meta_durable_head(receipt_candidate, dst_file_path.clone(), commit.mutation_lease.clone())
|
||||
.await;
|
||||
#[cfg(not(unix))]
|
||||
let _ = receipt_candidate;
|
||||
|
||||
// The commit no longer has a rollback path. Release the Windows
|
||||
// object identity guard before best-effort staging cleanup.
|
||||
#[cfg(windows)]
|
||||
@@ -1464,9 +1819,7 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if let Some(ref cleanup) = cleanup_path {
|
||||
let _ = self.delete_file(&dst_volume_dir, cleanup, true, false).await;
|
||||
} else if let Some(parent) = src_file_path.parent() {
|
||||
if cleanup_src_parent && let Some(parent) = src_file_path.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
|
||||
@@ -1485,7 +1838,12 @@ impl LocalDisk {
|
||||
self.io_backend.invalidate_cached_fd(dst_volume, part_path).await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Some(receipt_mutation) = receipt_mutation.as_mut() {
|
||||
receipt_mutation.complete();
|
||||
}
|
||||
Ok(RenameDataResp {
|
||||
multipart_source: None,
|
||||
old_data_dir: cleanup_data_dir,
|
||||
rollback_data_dir,
|
||||
cleanup_data_dir,
|
||||
|
||||
@@ -32,6 +32,7 @@ impl DiscoverySession {
|
||||
}
|
||||
}
|
||||
let mut transactions = BTreeSet::new();
|
||||
let mut seen_candidates = HashSet::new();
|
||||
let mut visited = 0usize;
|
||||
while !self.frames.is_empty()
|
||||
&& transactions.len() < usize::try_from(limit).map_err(|_| std::io::Error::from(ErrorKind::InvalidInput))?
|
||||
@@ -91,6 +92,9 @@ impl DiscoverySession {
|
||||
.find_map(|prefix| delete_transaction_marker_id(&name, prefix))
|
||||
};
|
||||
if let Some(transaction) = transaction {
|
||||
if !seen_candidates.insert((object.to_owned(), transaction)) {
|
||||
continue;
|
||||
}
|
||||
let physical = match self.object.as_ref() {
|
||||
Some(_) => self.root.clone(),
|
||||
None => self.root.join(object),
|
||||
@@ -101,15 +105,20 @@ impl DiscoverySession {
|
||||
continue;
|
||||
}
|
||||
let proof_root = physical.join(transaction.to_string());
|
||||
if self.object.is_some() {
|
||||
regular_file_exists(&proof_root.join(PREPARED)).map_err(std::io::Error::from)?;
|
||||
regular_file_exists(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE)).map_err(std::io::Error::from)?;
|
||||
transactions.insert((object.to_owned(), transaction));
|
||||
continue;
|
||||
}
|
||||
let prepared = regular_bytes(&proof_root.join(PREPARED)).map_err(std::io::Error::from)?;
|
||||
let receipt =
|
||||
regular_bytes(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE)).map_err(std::io::Error::from)?;
|
||||
// Only a fully bound journal can name a logical
|
||||
// key. Other records remain discoverable by their
|
||||
// physical key for canonical inspection to reject.
|
||||
let record = match prepared {
|
||||
Some(bytes) => serde_json::from_slice::<DeleteRecoveryJournal>(&bytes).ok(),
|
||||
None => receipt
|
||||
None => regular_bytes(&proof_root.join(DELETE_QUORUM_COMMITTED_FILE))
|
||||
.map_err(std::io::Error::from)?
|
||||
.filter(|bytes| !bytes.is_empty())
|
||||
.and_then(|bytes| serde_json::from_slice::<CommitReceipt>(&bytes).ok())
|
||||
.map(|receipt| receipt.journal),
|
||||
@@ -280,11 +289,19 @@ fn regular_metadata_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn regular_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
if std::fs::symlink_metadata(path).is_ok_and(|meta| meta.len() > MAX_METADATA) {
|
||||
return Err(DiskError::FileCorrupt);
|
||||
fn regular_file_exists(path: &Path) -> Result<bool> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_file() && meta.len() <= MAX_METADATA => Ok(true),
|
||||
Ok(_) => Err(DiskError::FileCorrupt),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(to_file_error(err).into()),
|
||||
}
|
||||
regular_metadata_bytes(path)
|
||||
}
|
||||
|
||||
fn regular_bytes(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
regular_file_exists(path)?
|
||||
.then(|| std::fs::read(path).map_err(|err| DiskError::from(to_file_error(err))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// A write/heal backup is not a delete transaction when the live metadata
|
||||
@@ -299,7 +316,7 @@ fn live_write_backup(root: &Path, transaction: Uuid) -> Result<bool> {
|
||||
DELETE_MARKER_ROLLBACK_FILE,
|
||||
DELETE_QUORUM_COMMITTED_FILE,
|
||||
] {
|
||||
if regular_bytes(&rollback.join(name))?.is_some() {
|
||||
if regular_file_exists(&rollback.join(name))? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
@@ -309,7 +326,7 @@ fn live_write_backup(root: &Path, transaction: Uuid) -> Result<bool> {
|
||||
RECOVERY_DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
] {
|
||||
let name = format!("{prefix}{transaction}");
|
||||
if regular_bytes(&root.join(&name))?.is_some() || regular_bytes(&rollback.join(name))?.is_some() {
|
||||
if regular_file_exists(&root.join(&name))? || regular_file_exists(&rollback.join(name))? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
@@ -530,13 +547,11 @@ struct ExplicitDeletePlan {
|
||||
proof_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn explicit_delete_plan(
|
||||
root: &Path,
|
||||
transaction: Uuid,
|
||||
fn explicit_delete_referenced_data_dirs(
|
||||
original: Option<&FileMeta>,
|
||||
journal: Option<&DeleteRecoveryJournal>,
|
||||
current_missing: bool,
|
||||
) -> Result<Option<ExplicitDeletePlan>> {
|
||||
) -> Result<Option<HashSet<Uuid>>> {
|
||||
if !current_missing
|
||||
|| journal
|
||||
.is_some_and(|journal| journal.target_digest.is_some() || (journal.original_digest.is_some() && original.is_none()))
|
||||
@@ -562,6 +577,19 @@ fn explicit_delete_plan(
|
||||
}
|
||||
referenced.extend(metadata.get_data_dirs()?.into_iter().flatten().filter(|id| !id.is_nil()));
|
||||
}
|
||||
Ok(Some(referenced))
|
||||
}
|
||||
|
||||
fn explicit_delete_plan(
|
||||
root: &Path,
|
||||
transaction: Uuid,
|
||||
original: Option<&FileMeta>,
|
||||
journal: Option<&DeleteRecoveryJournal>,
|
||||
current_missing: bool,
|
||||
) -> Result<Option<ExplicitDeletePlan>> {
|
||||
let Some(referenced) = explicit_delete_referenced_data_dirs(original, journal, current_missing)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let rollback = root.join(transaction.to_string());
|
||||
let marker_names = [
|
||||
DELETE_DATA_DIR_MARKER_PREFIX,
|
||||
@@ -669,7 +697,12 @@ fn canonical_uuid(name: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(name).ok().filter(|id| name == id.hyphenated().to_string())
|
||||
}
|
||||
|
||||
fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Result<DeleteRecoveryInspection> {
|
||||
pub(super) fn inspection_with_markers(
|
||||
root: &Path,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
) -> Result<(DeleteRecoveryInspection, bool)> {
|
||||
let rollback = root.join(transaction.to_string());
|
||||
let before = regular_metadata_bytes(&rollback.join(STORAGE_FORMAT_FILE_BACKUP))?;
|
||||
let current = regular_metadata_bytes(&root.join(STORAGE_FORMAT_FILE))?;
|
||||
@@ -739,6 +772,8 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
}
|
||||
fingerprint.update([u8::from(committed), u8::from(finished)]);
|
||||
let mut any_marker = false;
|
||||
let mut has_other_uuid_dir = false;
|
||||
let mut has_staged_dir = false;
|
||||
let mut descriptions = Vec::new();
|
||||
let entries = match std::fs::read_dir(root) {
|
||||
Ok(entries) => Some(entries),
|
||||
@@ -762,6 +797,7 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
}
|
||||
descriptions.push((name.clone(), kind.is_dir(), entry.metadata().map_err(to_file_error)?.len()));
|
||||
if kind.is_dir() && id.is_some_and(|id| !id.is_nil()) {
|
||||
has_other_uuid_dir |= id != Some(transaction);
|
||||
for child in std::fs::read_dir(entry.path()).map_err(to_file_error)? {
|
||||
let child = child.map_err(to_file_error)?;
|
||||
let kind = child.file_type().map_err(to_file_error)?;
|
||||
@@ -807,6 +843,7 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
return Err(DiskError::FileCorrupt);
|
||||
}
|
||||
if name == transaction.to_string() && kind.is_dir() {
|
||||
has_staged_dir = true;
|
||||
for file in std::fs::read_dir(child.path()).map_err(to_file_error)? {
|
||||
let file = file.map_err(to_file_error)?;
|
||||
let kind = file.file_type().map_err(to_file_error)?;
|
||||
@@ -924,9 +961,18 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
} else {
|
||||
DeleteRecoveryState::Absent
|
||||
};
|
||||
let explicit_delete_safe =
|
||||
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some();
|
||||
Ok(DeleteRecoveryInspection {
|
||||
let has_physical_data_dirs_or_markers = any_marker || has_other_uuid_dir || has_staged_dir;
|
||||
let explicit_delete_safe = match state {
|
||||
DeleteRecoveryState::NewPrepared if !has_physical_data_dirs_or_markers => {
|
||||
explicit_delete_referenced_data_dirs(before_meta.as_ref(), journal.as_ref(), current.is_none())?
|
||||
.is_some_and(|referenced| !referenced.contains(&transaction))
|
||||
}
|
||||
DeleteRecoveryState::LegacyAmbiguous | DeleteRecoveryState::NewPrepared => {
|
||||
explicit_delete_plan(root, transaction, before_meta.as_ref(), journal.as_ref(), current.is_none())?.is_some()
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let inspection = DeleteRecoveryInspection {
|
||||
protocol_version: DELETE_RECOVERY_PROTOCOL_VERSION,
|
||||
id: DeleteTransactionId {
|
||||
object: object.to_owned(),
|
||||
@@ -950,10 +996,22 @@ fn inspection(root: &Path, volume: &str, object: &str, transaction: Uuid) -> Res
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
fingerprint: fingerprint.finalize().into(),
|
||||
})
|
||||
};
|
||||
Ok((inspection, has_physical_data_dirs_or_markers))
|
||||
}
|
||||
|
||||
impl LocalDisk {
|
||||
// Native MPU source closure has its own conditional protocol. Generic
|
||||
// object recovery must never restore its head or acquire SourceRW under
|
||||
// an already-owned object metadata lease.
|
||||
fn reject_multipart_delete_recovery_mutation(&self, volume: &str, object: &str) -> Result<()> {
|
||||
multipart_staging::reject_noncanonical_scope(&self.root, self.io_root(), volume, object)?;
|
||||
if volume == RUSTFS_META_MULTIPART_BUCKET {
|
||||
return Err(DiskError::ConditionalDeleteConflict);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_recovery_predecessors(
|
||||
&self,
|
||||
volume: &str,
|
||||
@@ -1196,12 +1254,25 @@ impl LocalDisk {
|
||||
transaction: Uuid,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
Ok(self
|
||||
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner)
|
||||
.await?
|
||||
.0)
|
||||
}
|
||||
|
||||
async fn delete_recovery_inspect_owned_with_markers(
|
||||
&self,
|
||||
volume: &str,
|
||||
object: &str,
|
||||
transaction: Uuid,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<(DeleteRecoveryInspection, bool)> {
|
||||
let root = self.io_get_object_path(volume, object)?;
|
||||
self.io_get_object_path(volume, &format!("{object}/{transaction}"))?;
|
||||
let volume = volume.to_owned();
|
||||
let object = object.to_owned();
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&root, owner).await;
|
||||
os::run_blocking_namespace_operation(lease, move || Ok(inspection(&root, &volume, &object, transaction)))
|
||||
os::run_blocking_namespace_operation(lease, move || Ok(inspection_with_markers(&root, &volume, &object, transaction)))
|
||||
.await
|
||||
.map_err(to_file_error)?
|
||||
}
|
||||
@@ -1219,15 +1290,16 @@ impl LocalDisk {
|
||||
let journal = journal_bytes(journal)?;
|
||||
self.wait_for_startup_cleanup().await;
|
||||
let temporary = self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?;
|
||||
let root = self.io_get_object_path(volume, object)?;
|
||||
let (bucket, root) = self.io_get_bucket_and_object_path(volume, object)?;
|
||||
let destination = root.join(transaction.to_string());
|
||||
let destination_was_absent = original.is_none();
|
||||
let original = original.map(<[u8]>::to_vec);
|
||||
let sync = effective_durability(volume).syncs_commit_metadata();
|
||||
#[cfg(test)]
|
||||
let record_destination = destination.clone();
|
||||
let staging_root = temporary.clone();
|
||||
let staging_root = temporary;
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&destination, owner.clone()).await;
|
||||
let (staged, target) = os::run_blocking_namespace_operation(lease, move || {
|
||||
let (staged, target) = os::run_blocking_namespace_operation_with_sync(lease, sync, move || {
|
||||
std::fs::create_dir_all(&staging_root)?;
|
||||
let staged = Arc::new(
|
||||
tempfile::Builder::new()
|
||||
@@ -1248,8 +1320,8 @@ impl LocalDisk {
|
||||
run_owned_file_write_before_open(&record_destination.join(name));
|
||||
let mut file = std::fs::File::create(staged.path().join(name))?;
|
||||
std::io::Write::write_all(&mut file, bytes)?;
|
||||
if sync {
|
||||
file.sync_all()?;
|
||||
if sync && !bytes.is_empty() {
|
||||
file.sync_data()?;
|
||||
}
|
||||
}
|
||||
if sync {
|
||||
@@ -1280,25 +1352,26 @@ impl LocalDisk {
|
||||
// The directory owner accompanies the blocking rename, so cancelling
|
||||
// its waiter cannot delete staged files while activation is running.
|
||||
let publication_owner: Option<Arc<dyn Send + Sync>> = Some(Arc::new((owner.clone(), staged.clone())));
|
||||
os::rename_all_with_owner(
|
||||
staged.path(),
|
||||
&destination,
|
||||
&self.io_get_bucket_path(volume)?,
|
||||
&self.publication_root,
|
||||
publication_owner,
|
||||
)
|
||||
.await?;
|
||||
os::rename_all_with_owner(staged.path(), &destination, &bucket, &self.publication_root, publication_owner).await?;
|
||||
if sync {
|
||||
let bucket = self.io_get_bucket_path(volume)?;
|
||||
let mut dirs_to_sync = Vec::new();
|
||||
let mut path = root.as_path();
|
||||
loop {
|
||||
os::fsync_dir_with_owner(path, owner.clone()).await.map_err(to_file_error)?;
|
||||
if path == bucket {
|
||||
dirs_to_sync.push(path.to_path_buf());
|
||||
if !destination_was_absent || path == bucket {
|
||||
break;
|
||||
}
|
||||
path = path.parent().ok_or(DiskError::InvalidPath)?;
|
||||
}
|
||||
os::fsync_dir_with_owner(&temporary, owner.clone())
|
||||
let sync_owner = owner.clone();
|
||||
self.blocking_queue
|
||||
.run_fsync_cancellable(move |_| {
|
||||
let _owner = sync_owner;
|
||||
for dir in dirs_to_sync {
|
||||
os::fsync_dir_std(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
@@ -1315,6 +1388,8 @@ impl LocalDisk {
|
||||
authority: Option<&DeleteRecoveryJournal>,
|
||||
context: DeletePreparationContext,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
|
||||
let DeletePreparationContext {
|
||||
transaction,
|
||||
bucket_incarnation: incarnation,
|
||||
@@ -1406,6 +1481,8 @@ impl LocalDisk {
|
||||
action: DeleteRecoveryAction,
|
||||
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
|
||||
if !safe_path(object, false) || transaction.is_nil() {
|
||||
return Err(DiskError::InvalidPath);
|
||||
}
|
||||
@@ -1445,8 +1522,10 @@ impl LocalDisk {
|
||||
action: DeleteRecoveryAction,
|
||||
owner: Option<Arc<dyn Send + Sync>>,
|
||||
) -> Result<DeleteRecoveryInspection> {
|
||||
let mut observed = self
|
||||
.delete_recovery_inspect_owned(volume, object, transaction, owner.clone())
|
||||
self.reject_multipart_delete_recovery_mutation(volume, object)?;
|
||||
|
||||
let (mut observed, mut has_data_dir_work) = self
|
||||
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
if observed.fingerprint != expected_fingerprint {
|
||||
return Err(DiskError::OutdatedXLMeta);
|
||||
@@ -1572,8 +1651,8 @@ impl LocalDisk {
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
observed = self
|
||||
.delete_recovery_inspect_owned(volume, object, transaction, owner.clone())
|
||||
(observed, has_data_dir_work) = self
|
||||
.delete_recovery_inspect_owned_with_markers(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
} else {
|
||||
return Ok(observed);
|
||||
@@ -1680,17 +1759,22 @@ impl LocalDisk {
|
||||
journal: journal.clone(),
|
||||
finished: false,
|
||||
};
|
||||
write_delete_rollback_file(
|
||||
let inspect_after = (!cfg!(feature = "e2e-test-hooks")).then_some(object);
|
||||
let inspected = write_delete_rollback_files(
|
||||
self.io_root(),
|
||||
volume,
|
||||
&root,
|
||||
transaction,
|
||||
DELETE_QUORUM_COMMITTED_FILE,
|
||||
&serde_json::to_vec(&receipt)?,
|
||||
&[],
|
||||
&[(DELETE_QUORUM_COMMITTED_FILE, &serde_json::to_vec(&receipt)?)],
|
||||
inspect_after,
|
||||
owner.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.delete_recovery_barrier(volume, object, transaction, "DECISION").await?;
|
||||
if let Some(inspected) = inspected {
|
||||
return Ok(inspected);
|
||||
}
|
||||
}
|
||||
DeleteRecoveryAction::FinishCommit => {
|
||||
if !matches!(observed.state, DeleteRecoveryState::QuorumCommitted | DeleteRecoveryState::CommitFinished)
|
||||
@@ -1701,7 +1785,7 @@ impl LocalDisk {
|
||||
if observed.state == DeleteRecoveryState::CommitFinished {
|
||||
return Ok(observed);
|
||||
}
|
||||
for dir in &journal.data_dirs {
|
||||
for dir in journal.data_dirs.iter().filter(|_| has_data_dir_work) {
|
||||
self.reserve_version_delete_with_namespace_owner(volume, object, *dir, transaction, owner.clone())
|
||||
.await?;
|
||||
}
|
||||
@@ -1731,27 +1815,26 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.commit_reserved_version_delete_with_namespace_owner(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
for name in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
|
||||
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
if has_data_dir_work {
|
||||
self.commit_reserved_version_delete_with_namespace_owner(volume, object, transaction, owner.clone())
|
||||
.await?;
|
||||
os::remove_files_and_fsync_with_owner(
|
||||
&rollback,
|
||||
&[STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE],
|
||||
effective_durability(volume).syncs_commit_metadata(),
|
||||
None,
|
||||
owner.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
let (_, complete) = self
|
||||
.finish_version_delete(volume, object, transaction, true, owner.clone(), None)
|
||||
.await?;
|
||||
if !complete {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
}
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(&rollback, owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
let (_, complete) = self
|
||||
.finish_version_delete(volume, object, transaction, true, owner.clone())
|
||||
.await?;
|
||||
if !complete {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
for dir in &journal.data_dirs {
|
||||
for dir in journal.data_dirs.iter().filter(|_| has_data_dir_work) {
|
||||
let staged = rollback.join(dir.to_string());
|
||||
match fs::symlink_metadata(&staged).await {
|
||||
Ok(metadata) if metadata.is_dir() => {
|
||||
@@ -1798,52 +1881,97 @@ impl LocalDisk {
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
}
|
||||
}
|
||||
write_delete_rollback_file(self.io_root(), volume, &root, transaction, FINISHED, &[], owner.clone()).await?;
|
||||
let receipt = CommitReceipt {
|
||||
journal: journal.clone(),
|
||||
finished: true,
|
||||
};
|
||||
write_delete_rollback_file(
|
||||
let receipt_bytes = serde_json::to_vec(&receipt)?;
|
||||
let remove_before_publish: &[&'static str] = if has_data_dir_work {
|
||||
&[]
|
||||
} else {
|
||||
&[STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE]
|
||||
};
|
||||
if let Some(inspected) = write_delete_rollback_files(
|
||||
self.io_root(),
|
||||
volume,
|
||||
&root,
|
||||
transaction,
|
||||
DELETE_QUORUM_COMMITTED_FILE,
|
||||
&serde_json::to_vec(&receipt)?,
|
||||
remove_before_publish,
|
||||
&[(FINISHED, &[]), (DELETE_QUORUM_COMMITTED_FILE, &receipt_bytes)],
|
||||
Some(object),
|
||||
owner.clone(),
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
{
|
||||
return Ok(inspected);
|
||||
}
|
||||
}
|
||||
DeleteRecoveryAction::FinalizeCommit => {
|
||||
if observed.state != DeleteRecoveryState::CommitFinished {
|
||||
return Err(DiskError::DiskOngoingReq);
|
||||
}
|
||||
let bucket_path = self.io_get_bucket_path(volume)?;
|
||||
let mut file_leases = Vec::with_capacity(3);
|
||||
for name in [TARGET, PREPARED, FINISHED] {
|
||||
match os::remove_file_with_owner(rollback.join(name), owner.clone()).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(to_file_error(err).into()),
|
||||
let path = rollback.join(name);
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&path, owner.clone()).await;
|
||||
file_leases.push((path, lease));
|
||||
}
|
||||
let receipt_path = rollback.join(DELETE_QUORUM_COMMITTED_FILE);
|
||||
let receipt_lease = os::acquire_namespace_mutation_lease_with_owner(&receipt_path, owner.clone()).await;
|
||||
let prune_paths: Vec<_> = rollback
|
||||
.ancestors()
|
||||
.take_while(|dir| *dir != bucket_path.as_path() && dir.starts_with(&bucket_path))
|
||||
.map(Path::to_path_buf)
|
||||
.collect();
|
||||
let mut prune_dirs = Vec::with_capacity(prune_paths.len());
|
||||
for dir in prune_paths {
|
||||
let lease = os::acquire_namespace_mutation_lease_with_owner(&dir, owner.clone()).await;
|
||||
prune_dirs.push((dir, lease));
|
||||
}
|
||||
let sync = effective_durability(volume).syncs_commit_metadata();
|
||||
let (rollback_buf, root_buf, volume_str, object_str) =
|
||||
(rollback.clone(), root.clone(), volume.to_owned(), object.to_owned());
|
||||
let publication_root = self.publication_root.clone();
|
||||
return os::run_blocking_namespace_operation_with_sync(receipt_lease, sync, move || {
|
||||
let _publication_root = publication_root;
|
||||
for (path, _) in &file_leases {
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, path);
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
if effective_durability(volume).syncs_commit_metadata() {
|
||||
os::fsync_dir_with_owner(&rollback, owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
os::remove_file_with_owner(rollback.join(DELETE_QUORUM_COMMITTED_FILE), owner.clone())
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
self.delete_unleased_with_namespace_owner(
|
||||
volume,
|
||||
&format!("{object}/{transaction}"),
|
||||
&DeleteOptions {
|
||||
immediate: true,
|
||||
..Default::default()
|
||||
},
|
||||
owner.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if sync {
|
||||
os::fsync_dir_std(&rollback_buf)?;
|
||||
}
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, &receipt_path);
|
||||
std::fs::remove_file(&receipt_path)?;
|
||||
for (dir_path, _) in &prune_dirs {
|
||||
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||
os::prepared_publication_test_hooks::run(os::prepared_publication_test_hooks::Stage::Remove, dir_path);
|
||||
if let Err(err) = std::fs::remove_dir(dir_path) {
|
||||
if is_dir_not_empty_error(&err) {
|
||||
break;
|
||||
}
|
||||
if !is_benign_object_rmdir_error(&err) {
|
||||
return Err(std::io::Error::other(FileAccessDeniedWithContext {
|
||||
path: dir_path.clone(),
|
||||
source: err,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let inspected =
|
||||
inspection_with_markers(&root_buf, &volume_str, &object_str, transaction).map(|(state, _)| state);
|
||||
drop((file_leases, prune_dirs));
|
||||
Ok(inspected)
|
||||
})
|
||||
.await
|
||||
.map_err(to_file_error)?;
|
||||
}
|
||||
DeleteRecoveryAction::Rollback => {
|
||||
if observed.state != DeleteRecoveryState::NewPrepared || !observed.rollback_data_complete {
|
||||
@@ -2605,6 +2733,149 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_known_heal_backup_inspection_preserves_unproven_owners_after_reopen() {
|
||||
for change in [
|
||||
"unchanged",
|
||||
"generation",
|
||||
"tier-object",
|
||||
"tier-version",
|
||||
"missing-free-owner",
|
||||
"replaced-backup",
|
||||
] {
|
||||
let (_temp, disk, mut info, _) = fixture().await;
|
||||
info.metadata.insert("etag".to_owned(), "same-visible-etag".to_owned());
|
||||
info.set_object_transaction_epoch(Uuid::new_v4());
|
||||
let free_id = Uuid::new_v4();
|
||||
info.set_tier_free_version_id(&free_id.to_string());
|
||||
let mut remote = test_file_info("object", Uuid::nil(), Some(Uuid::new_v4()), None);
|
||||
remote.mod_time = info.mod_time;
|
||||
remote.transition_status = rustfs_filemeta::TRANSITION_COMPLETE.to_owned();
|
||||
remote.transition_tier = "WARM".to_owned();
|
||||
remote.transitioned_objname = "remote/original".to_owned();
|
||||
remote.transition_version_state = rustfs_filemeta::TransitionVersionState::Exact;
|
||||
remote.transition_version = Some("original-provider-version".to_owned());
|
||||
rustfs_utils::http::metadata_compat::insert_str(
|
||||
&mut remote.metadata,
|
||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||
"ab".repeat(32),
|
||||
);
|
||||
let metadata = |remote: FileInfo, current: FileInfo| {
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(remote).expect("seed transitioned predecessor");
|
||||
meta.add_version(current)
|
||||
.expect("commit local replacement and its free-version owner");
|
||||
meta
|
||||
};
|
||||
let original = metadata(remote.clone(), info.clone());
|
||||
let (_, free) = original.find_version(Some(free_id)).expect("original free-version owner");
|
||||
assert!(free.free_version(), "fixture must retain a separate remote cleanup owner");
|
||||
let backup_bytes = original.marshal_msg().expect("encode historical backup");
|
||||
let transaction = inline_metadata_rollback_dir(Uuid::nil(), &original);
|
||||
let root = disk.io_get_object_path("recovery", "object").expect("object path");
|
||||
let backup_path = root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
fs::create_dir_all(backup_path.parent().expect("backup parent"))
|
||||
.await
|
||||
.expect("create backup directory");
|
||||
fs::write(&backup_path, &backup_bytes)
|
||||
.await
|
||||
.expect("retain historical backup");
|
||||
|
||||
let mut current = info.clone();
|
||||
match change {
|
||||
"generation" => current.set_object_transaction_epoch(Uuid::new_v4()),
|
||||
"tier-object" => remote.transitioned_objname = "remote/successor".to_owned(),
|
||||
"tier-version" => remote.transition_version = Some("successor-provider-version".to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
let mut current_meta = metadata(remote, current);
|
||||
if change == "missing-free-owner" {
|
||||
current_meta
|
||||
.versions
|
||||
.retain(|version| version.header.version_id != Some(free_id));
|
||||
} else if change == "replaced-backup" {
|
||||
let replacement = root.join("replacement-backup");
|
||||
fs::write(&replacement, &backup_bytes).await.expect("write replacement inode");
|
||||
fs::rename(replacement, &backup_path)
|
||||
.await
|
||||
.expect("replace backup without changing its bytes");
|
||||
}
|
||||
let current_bytes = current_meta.marshal_msg().expect("encode current metadata");
|
||||
fs::write(root.join(STORAGE_FORMAT_FILE), ¤t_bytes)
|
||||
.await
|
||||
.expect("publish readable current object");
|
||||
let visible = disk
|
||||
.read_version(
|
||||
"",
|
||||
"recovery",
|
||||
"object",
|
||||
"",
|
||||
&ReadOptions {
|
||||
read_data: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("current object remains physically readable");
|
||||
assert_eq!(visible.data, info.data, "{change}: current payload");
|
||||
assert_eq!(visible.mod_time, info.mod_time, "{change}: unchanged modification time");
|
||||
assert_eq!(visible.metadata.get("etag"), info.metadata.get("etag"), "{change}: unchanged ETag");
|
||||
|
||||
let before = disk
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("query known backup without inferring commit authority");
|
||||
let endpoint = disk.endpoint();
|
||||
drop(disk);
|
||||
let reopened = LocalDisk::new(&endpoint, false).await.expect("reopen original disk endpoint");
|
||||
// Linux I/O paths belong to the open disk's mount descriptor.
|
||||
let root = reopened
|
||||
.io_get_object_path("recovery", "object")
|
||||
.expect("reopened object path");
|
||||
let backup_path = root.join(transaction.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||
let after = reopened
|
||||
.inspect_delete_transaction("recovery", "object", transaction)
|
||||
.await
|
||||
.expect("known historical backup remains queryable after reopen");
|
||||
assert_eq!(after, before, "{change}: inspection must preserve the complete evidence snapshot");
|
||||
assert!(after.journal.is_none(), "{change}: no historical success or delete authority exists");
|
||||
assert_eq!(after.backup_digest, Some(digest(&backup_bytes)), "{change}: retained C-class evidence");
|
||||
assert!(!after.explicit_delete_safe, "{change}: current data forbids legacy discard");
|
||||
let expected_state = if matches!(change, "unchanged" | "replaced-backup") {
|
||||
DeleteRecoveryState::Absent
|
||||
} else {
|
||||
DeleteRecoveryState::LegacyAmbiguous
|
||||
};
|
||||
assert_eq!(
|
||||
after.state, expected_state,
|
||||
"{change}: delete-owner semantics remain separate from C classification"
|
||||
);
|
||||
let page = reopened
|
||||
.discover_delete_transactions("recovery", Some("object"), None, 128)
|
||||
.await
|
||||
.expect("discover only delete-recovery work");
|
||||
assert_eq!(
|
||||
page.transactions.iter().any(|id| id.transaction == transaction),
|
||||
expected_state == DeleteRecoveryState::LegacyAmbiguous,
|
||||
"{change}: a live backup must not become a delete blocker"
|
||||
);
|
||||
assert_eq!(fs::read(&backup_path).await.expect("retained backup bytes"), backup_bytes, "{change}");
|
||||
assert_eq!(
|
||||
fs::read(root.join(STORAGE_FORMAT_FILE))
|
||||
.await
|
||||
.expect("retained current bytes"),
|
||||
current_bytes,
|
||||
"{change}"
|
||||
);
|
||||
let backup = FileMeta::load(&backup_bytes).expect("decode retained owner evidence");
|
||||
assert_eq!(
|
||||
backup.find_version(Some(free_id)).expect("retained free-version owner").1,
|
||||
free,
|
||||
"{change}: ordinary-object readability cannot release remote ownership"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_live_external_backup_rejects_partial_version_delete() {
|
||||
let (_temp, disk, mut info, _) = fixture().await;
|
||||
@@ -2744,6 +3015,152 @@ mod tests {
|
||||
assert_eq!(disk.read_all("recovery", "object/xl.meta").await.unwrap().as_ref(), original);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_cancelled_finalize_pins_root_after_last_disk_owner_drops() {
|
||||
use crate::disk::os::prepared_publication_test_hooks as hooks;
|
||||
use futures::FutureExt;
|
||||
|
||||
struct ReleaseOnDrop(Option<std::sync::mpsc::Sender<()>>);
|
||||
impl Drop for ReleaseOnDrop {
|
||||
fn drop(&mut self) {
|
||||
if let Some(release) = self.0.take() {
|
||||
let _ = release.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (temp, native, _info, _original) = fixture().await;
|
||||
let root_file = Arc::downgrade(native.publication_root.directory());
|
||||
assert!(Arc::ptr_eq(native.publication_root.directory(), &native.mount_lease));
|
||||
// The constructor's idle GC task also owns this FD. Drain that existing
|
||||
// task so it cannot mask a missing pin in the Finalize callback.
|
||||
let exit = native.exit_signal.as_ref().expect("fixture cleanup lifecycle");
|
||||
exit.send(()).expect("stop the existing idle cleanup task");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while exit.receiver_count() != 0 || root_file.strong_count() != 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("only LocalDisk's publication and mount fields may retain the initial root FD");
|
||||
let transaction = Uuid::new_v4();
|
||||
let applied = apply(&native, transaction).await;
|
||||
let committed = native
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
applied.fingerprint,
|
||||
DeleteRecoveryAction::PublishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("publish actual delete decision");
|
||||
let finished = native
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
committed.fingerprint,
|
||||
DeleteRecoveryAction::FinishCommit,
|
||||
)
|
||||
.await
|
||||
.expect("finish actual committed deletion");
|
||||
assert_eq!(finished.state, DeleteRecoveryState::CommitFinished);
|
||||
let physical_object = temp.path().join("recovery/object");
|
||||
let physical_rollback = physical_object.join(transaction.to_string());
|
||||
assert!(physical_rollback.join(PREPARED).is_file());
|
||||
assert!(physical_rollback.join(DELETE_QUORUM_COMMITTED_FILE).is_file());
|
||||
let io_root = native.io_root().to_path_buf();
|
||||
assert!(io_root.starts_with("/proc/self/fd"), "exercise the real borrowed Linux FD path");
|
||||
let canonical_root = std::fs::canonicalize(temp.path()).expect("actual root identity");
|
||||
let fd_alias = io_root.components().collect::<PathBuf>();
|
||||
let io_rollback = native
|
||||
.io_get_object_path("recovery", &format!("object/{transaction}"))
|
||||
.expect("actual IO rollback path");
|
||||
let receipt_path = io_rollback.join(DELETE_QUORUM_COMMITTED_FILE);
|
||||
let (entered, reached) = tokio::sync::oneshot::channel();
|
||||
let (release, resumed) = std::sync::mpsc::channel::<()>();
|
||||
let release = ReleaseOnDrop(Some(release));
|
||||
let hook = hooks::install_at(hooks::Stage::Remove, &io_rollback.join(PREPARED), move || {
|
||||
let _ = entered.send(());
|
||||
let _ = resumed.recv();
|
||||
});
|
||||
let mut native = Some(Arc::new(native));
|
||||
let weak = Arc::downgrade(native.as_ref().expect("fixture disk owner"));
|
||||
let finalizing = native.as_ref().expect("fixture disk owner").clone();
|
||||
let mut task = Some(tokio::spawn(async move {
|
||||
finalizing
|
||||
.settle_delete_transaction(
|
||||
"recovery",
|
||||
"object",
|
||||
transaction,
|
||||
finished.fingerprint,
|
||||
DeleteRecoveryAction::FinalizeCommit,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
let observations = std::panic::AssertUnwindSafe(async {
|
||||
tokio::time::timeout(Duration::from_secs(10), reached)
|
||||
.await
|
||||
.expect("actual Finalize remove callback must pause")
|
||||
.expect("actual physical Remove hook");
|
||||
let cancelling = task.take().expect("live finalize waiter");
|
||||
cancelling.abort();
|
||||
assert!(cancelling.await.expect_err("cancel the real waiter").is_cancelled());
|
||||
drop(native.take());
|
||||
assert!(weak.upgrade().is_none(), "no test or cancelled future may retain a LocalDisk");
|
||||
assert_eq!(
|
||||
root_file.strong_count(),
|
||||
1,
|
||||
"only the actual callback may retain the root FD after disk retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_link(&fd_alias).expect("the physical callback must still own its root FD"),
|
||||
canonical_root,
|
||||
"the borrowed FD alias must keep the same actual disk identity"
|
||||
);
|
||||
assert!(
|
||||
physical_rollback.join(PREPARED).is_file(),
|
||||
"paused callback has not removed the actual prepared record"
|
||||
);
|
||||
assert!(
|
||||
physical_rollback.join(DELETE_QUORUM_COMMITTED_FILE).is_file(),
|
||||
"the actual final witness still exists before resume"
|
||||
);
|
||||
})
|
||||
.catch_unwind()
|
||||
.await;
|
||||
if let Some(task) = task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
drop(native.take());
|
||||
drop(release);
|
||||
drop(hook);
|
||||
// This path-only lease does not hold a root handle. Acquiring it proves
|
||||
// the actual callback, including its inline inspection, has returned.
|
||||
let drained = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
os::acquire_namespace_mutation_lease_with_owner(&receipt_path, None),
|
||||
)
|
||||
.await
|
||||
.map(drop);
|
||||
if let Err(panic) = observations {
|
||||
std::panic::resume_unwind(panic);
|
||||
}
|
||||
drained.expect("cancelled physical Finalize callback must finish");
|
||||
for name in [TARGET, PREPARED, FINISHED, DELETE_QUORUM_COMMITTED_FILE] {
|
||||
assert!(
|
||||
!physical_rollback.join(name).exists(),
|
||||
"actual original-volume proof must be reclaimed: {name}"
|
||||
);
|
||||
}
|
||||
assert!(!physical_rollback.exists(), "the real rollback directory is pruned after proof removal");
|
||||
assert!(!physical_object.exists(), "completed deletion leaves no original-volume object residue");
|
||||
assert!(temp.path().join("recovery").is_dir(), "finalization must preserve its original volume");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn delete_recovery_activation_is_complete_before_metadata_mutation_even_after_cancel() {
|
||||
@@ -5233,3 +5650,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multipart_scope_tests {
|
||||
include!("multipart_delete_recovery_scope_tests.rs");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use super::*;
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use futures::FutureExt;
|
||||
|
||||
#[inline(never)]
|
||||
fn heap_scope<F: std::future::Future>(build: impl FnOnce() -> F) -> std::pin::Pin<Box<F>> {
|
||||
Box::pin(build())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_delete_recovery_cannot_restore_or_prepare_any_physical_multipart_scope() {
|
||||
let temporary = tempfile::tempdir().expect("recovery scope disk");
|
||||
let endpoint = Endpoint::try_from(temporary.path().to_string_lossy().as_ref()).expect("local endpoint");
|
||||
let disk = heap_scope(|| LocalDisk::new(&endpoint, false)).await.expect("local disk");
|
||||
let hash = "a".repeat(64);
|
||||
let upload = Uuid::new_v4().to_string();
|
||||
let object = format!("{hash}/{upload}");
|
||||
let source = disk
|
||||
.io_get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
|
||||
.expect("actual source root");
|
||||
std::fs::create_dir_all(&source).expect("existing source root");
|
||||
// Scope rejection precedes any attempt to interpret or replace these bytes.
|
||||
let head = source.join(STORAGE_FORMAT_FILE);
|
||||
std::fs::write(&head, b"existing source head must not be read or replaced").expect("existing head bytes");
|
||||
let original = std::fs::read(&head).expect("original physical bytes");
|
||||
let logical = disk
|
||||
.get_object_path(RUSTFS_META_MULTIPART_BUCKET, &object)
|
||||
.expect("configured metadata key");
|
||||
let owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&logical, None).await);
|
||||
let transaction = Uuid::new_v4();
|
||||
let aliases = [
|
||||
(RUSTFS_META_MULTIPART_BUCKET.to_owned(), object.clone()),
|
||||
(format!("{RUSTFS_META_MULTIPART_BUCKET}/."), object.clone()),
|
||||
(format!("{RUSTFS_META_MULTIPART_BUCKET}/{hash}"), upload),
|
||||
(crate::disk::RUSTFS_META_BUCKET.to_owned(), format!("multipart/{object}")),
|
||||
];
|
||||
for (volume, object) in aliases {
|
||||
let result = heap_scope(|| {
|
||||
disk.delete_recovery_settle(&volume, &object, transaction, [0; 32], DeleteRecoveryAction::Rollback, owner.clone())
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("scope is rejected before metadata lock acquisition");
|
||||
assert_eq!(
|
||||
result.expect_err("generic recovery cannot mutate MPI"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
let result = heap_scope(|| {
|
||||
disk.delete_recovery_settle_owned(
|
||||
&volume,
|
||||
&object,
|
||||
transaction,
|
||||
[0; 32],
|
||||
DeleteRecoveryAction::Rollback,
|
||||
owner.clone(),
|
||||
)
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("owned rollback is rejected before I/O");
|
||||
assert_eq!(
|
||||
result.expect_err("owned recovery cannot restore MPI head"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
let result = heap_scope(|| {
|
||||
disk.prepare_delete_recovery(
|
||||
&volume,
|
||||
&object,
|
||||
Some(&original),
|
||||
&[],
|
||||
None,
|
||||
DeletePreparationContext {
|
||||
transaction,
|
||||
bucket_incarnation: None,
|
||||
namespace_owner: owner.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.now_or_never()
|
||||
.expect("preparation is rejected before decoding or writing metadata");
|
||||
assert_eq!(
|
||||
result.expect_err("generic recovery cannot prepare MPI"),
|
||||
DiskError::ConditionalDeleteConflict
|
||||
);
|
||||
assert_eq!(std::fs::read(&head).expect("source remains present"), original);
|
||||
assert_eq!(std::fs::read_dir(&source).expect("source inventory").count(), 1);
|
||||
}
|
||||
assert!(
|
||||
disk.reject_multipart_delete_recovery_mutation("ordinary-user-bucket", "object")
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user