mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 21:56:03 +00:00
Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6f5f22c0 | |||
| d7b6a8c10d | |||
| e8ffd72575 | |||
| 0b03d535c3 | |||
| 3afb389247 | |||
| 86b6569071 | |||
| 0940fbe1b2 | |||
| 9e7c5dc932 | |||
| 45806bf295 | |||
| 42f00d8532 | |||
| 3e46d61a91 | |||
| 88183daf10 | |||
| 8be1e9b2c1 | |||
| 9b4b366209 | |||
| 43436ad5a7 | |||
| 0c7b6188b2 | |||
| 3ffc3704af | |||
| de3ac27a6a | |||
| 27d66c159f | |||
| 2d0e82e9f7 | |||
| c92fabea3a | |||
| b3b0d892bb | |||
| d9ae1654b6 | |||
| 15949180c9 | |||
| f65305e97f | |||
| 28ac3ab4e7 | |||
| cf41b1d3cf | |||
| ca876930b3 | |||
| c05b3ee01e | |||
| 1076e2fb4f | |||
| 51e6733a9c | |||
| e546ae9c62 | |||
| ed2b2cdd19 | |||
| 35b5cfcf8d | |||
| 084477e079 | |||
| bb1b5dea16 | |||
| 1c87413788 | |||
| e1fee0569a | |||
| a6e5bdcbd2 | |||
| 923f51cf12 | |||
| d7c2fc7587 | |||
| 7db206466b | |||
| e549252ac6 | |||
| 17807b05fb | |||
| e16274ab14 | |||
| 5eecd657e7 | |||
| 929d906131 | |||
| 6b802a91eb | |||
| 14b3cffe8f | |||
| c62384e58d | |||
| 789f1832a4 | |||
| 3f30f6c139 | |||
| 584a52ce3d | |||
| 76e025a50c | |||
| b40eb193df | |||
| 023c987397 | |||
| a4b265bf77 | |||
| c176220a34 | |||
| 82ac2dff05 | |||
| e24eae9eaa | |||
| 8617f2701b | |||
| 8d5592f83e | |||
| b5c9229e77 | |||
| a974e50b1d | |||
| 081910e825 | |||
| a722fa80d5 | |||
| 9ecb500cbf | |||
| 7f7e4fe40b | |||
| 684e6f313a | |||
| b3a02f305e | |||
| 0ea9c19569 | |||
| ae662da256 | |||
| b5d33a1f4e | |||
| d288496752 | |||
| 40baedba4f | |||
| 0c3fd48c22 | |||
| 3e8b0e7c83 | |||
| dd2af9ff88 | |||
| 5db0e14d04 | |||
| 9429225cf4 | |||
| 857584b3c2 | |||
| cfda9b451c | |||
| 354e49de4c | |||
| 8e987ce0a6 | |||
| 8462b3492b | |||
| 929f836e40 | |||
| c7dec044eb | |||
| ac44f8968e | |||
| 14a59f7770 | |||
| 084e9c4b82 | |||
| 46907c05cf | |||
| 921cc7ad94 | |||
| bfa8df00e7 | |||
| 33a8469e27 | |||
| 128080ceb9 | |||
| ae9fe62fb1 | |||
| 929a9f27c4 | |||
| 15376a7fa6 | |||
| 5d789006dd | |||
| f4bd53a63c | |||
| 21fee7f7ff | |||
| 23ed00fc85 | |||
| f49ffa2fec | |||
| ee5f287324 | |||
| 158c613d0e | |||
| a950f914ca | |||
| 1de9b40a30 | |||
| 831468b2e9 | |||
| 600d037b25 | |||
| 3fa2b334be | |||
| 1b549d5907 | |||
| 11c4ce96eb | |||
| 4508a0985d | |||
| ed1b9f25d6 | |||
| 274c2bf402 | |||
| 9aebcefa9c | |||
| 1748814bbf | |||
| ee5f76c180 | |||
| 73957d0faf | |||
| 4d68c32b75 | |||
| 8c6689ff13 | |||
| 595f9f662d | |||
| 4a2b15cb82 | |||
| 2ba7f95547 | |||
| 7e0c67111b | |||
| 0008cbcb29 | |||
| 7b3dad6bae | |||
| c0754f5b1c | |||
| 0db77be5c6 | |||
| 5dc0e3b402 | |||
| cc5487e7de | |||
| 0b05b6c6ff | |||
| c507da8f75 | |||
| 550dabeffd | |||
| 4d7f0344d3 | |||
| 084338add6 | |||
| 8d339da706 | |||
| 817ad0a682 | |||
| 80321e5bb4 | |||
| e7475cfa4d | |||
| 72e85210f1 | |||
| 8c15025a5a | |||
| 6f2ec66263 | |||
| 62542ddc57 | |||
| a8bb53218d | |||
| 0a9f0f59a7 | |||
| 5355d9f8f8 | |||
| 50d7a049ee | |||
| 3149c87cf2 | |||
| b7fa6a4615 | |||
| 6fe83f87a4 | |||
| df6981d88e | |||
| e65788f2e8 | |||
| 590adad5ae | |||
| be5d14c985 | |||
| 703086d71d | |||
| a159f312f0 | |||
| 2f6f095298 | |||
| efd8ef005f | |||
| 60fa33773a | |||
| ee752b0b03 | |||
| 99c1f4418b | |||
| 7ce0ac72cf | |||
| 944e26d432 | |||
| f0b0a99260 | |||
| 33ddc10ffd |
@@ -1,2 +1,2 @@
|
|||||||
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
|
sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||||
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=f0c78fdb93471575d9a64c5c46eae6c806bdd0bc10a6e33d7fb574aabd8db5a3
|
sha256-darwin=874c881d7b45f12378a5817c7f42c95c4981960a2ec9ce12dcf4af239ae1f9d5
|
||||||
sha256-linux=03ed7016cab672de9320e31375a0358eceacb4408b0e79cf063614fa7c878b87
|
sha256-linux=9515861be899ceb10e2e0ef93c34208bb7a7a8a7f8067a02db4cfba23270ebd6
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
|
||||||
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=0fe8408874ccec3620262a9812d67920ddd72dc9edf0e36e0d0aed3f8bad026e
|
sha256=0e338d305260229e17ccfb2adc48a6212dbdfea36a9ebfb5a4e0d38658e6cc45
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
|||||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||||
@echo "🧪 Checking test wiring..."
|
@echo "🧪 Checking test wiring..."
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
|
||||||
|
|
||||||
.PHONY: log-analyzer-rules-check
|
.PHONY: log-analyzer-rules-check
|
||||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_hotpath_warp_ab_gate.sh
|
./scripts/test_hotpath_warp_ab_gate.sh
|
||||||
./scripts/test_hotpath_warp_abba.sh
|
./scripts/test_hotpath_warp_abba.sh
|
||||||
./scripts/test_scanner_validation_harness.sh
|
./scripts/test_scanner_validation_harness.sh
|
||||||
|
./scripts/test_scanner_heal_checkpoint_crash_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_authority_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_scoped_ack_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_legacy_rollback_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_g14_multiset_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_scheduler_pressure_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_status_outcome_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_maintenance_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_w13_mrf_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_w16_recovery_evidence.sh
|
||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
@@ -39,6 +49,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_python_bin.sh
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||||
|
|||||||
@@ -197,6 +197,12 @@ test-group = 'e2e-cluster-nightly'
|
|||||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||||
test-group = 'e2e-vault'
|
test-group = 'e2e-vault'
|
||||||
|
|
||||||
|
# This four-disk, 65-member rollback probe already drives up to 32 concurrent
|
||||||
|
# durable deletions. Reserve this nextest run's capacity for its progress oracle.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(=store::init::tests::dispatch_manifest_rollback_bounded_concurrency_reaches_tail_behind_slow_member)'
|
||||||
|
threads-required = "num-test-threads"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 2,
|
||||||
"cases": {
|
"cases": {
|
||||||
"background-target-restart": {
|
"background-target-restart": {
|
||||||
"gate": "G14",
|
"gate": "G14",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"min_objects": 9,
|
"min_objects": 9,
|
||||||
"max_objects": 65,
|
"max_objects": 65,
|
||||||
"topology": {"nodes": 4, "drives_per_node": 1},
|
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||||
|
"erasure": {"data_blocks": 2, "parity_blocks": 2},
|
||||||
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||||
},
|
},
|
||||||
"background-target-crash": {
|
"background-target-crash": {
|
||||||
@@ -27,30 +28,422 @@
|
|||||||
"min_objects": 9,
|
"min_objects": 9,
|
||||||
"max_objects": 65,
|
"max_objects": 65,
|
||||||
"topology": {"nodes": 4, "drives_per_node": 1},
|
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||||
|
"erasure": {"data_blocks": 2, "parity_blocks": 2},
|
||||||
"scope": "Target process killed during partial background rebuild, real unclean-shutdown marker, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
"scope": "Target process killed during partial background rebuild, real unclean-shutdown marker, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||||
|
},
|
||||||
|
"ec84-target-drive-restart": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W20/W21",
|
||||||
|
"lane": "e2e-distributed",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "distributed::heal_test::three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart",
|
||||||
|
"oracle": "ec84-target-drive-restart.json",
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"min_objects": 5,
|
||||||
|
"max_objects": 5,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 4},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"scope": "3-node x 4-drive single-set EC8+4, graceful target restart, preformatted replacement drive, exact unversioned S3 bodies and physical target shards; not mixed-version, multi-pool or long-window ABBA."
|
||||||
|
},
|
||||||
|
"background-target-restart-ec8-4": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart",
|
||||||
|
"oracle": "background-target-restart-ec8-4.json",
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 4},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"scope": "Target process restart during partial background rebuild on a single 3x4 EC8+4 set; exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool."
|
||||||
|
},
|
||||||
|
"background-target-crash-ec8-4": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash",
|
||||||
|
"oracle": "background-target-crash-ec8-4.json",
|
||||||
|
"evidence": "process-crash-restart",
|
||||||
|
"unclean_shutdown_marker": true,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 4},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"scope": "Target process killed during partial background rebuild on a single 3x4 EC8+4 set; real unclean-shutdown marker, exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool."
|
||||||
|
},
|
||||||
|
"background-target-restart-ec8-4-multi-set": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart",
|
||||||
|
"oracle": "background-target-restart-ec8-4-multi-set.json",
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 8},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"sets": 2,
|
||||||
|
"pools": 1,
|
||||||
|
"scope": "Target process restart during partial background rebuild on a 3x8 EC8+4 layout with two erasure sets in one pool; exact unversioned S3 bodies and replacement-drive shards; not power loss or multi-pool."
|
||||||
|
},
|
||||||
|
"background-target-crash-ec8-4-multi-pool": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_across_multi_pool_after_background_target_crash",
|
||||||
|
"oracle": "background-target-crash-ec8-4-multi-pool.json",
|
||||||
|
"evidence": "process-crash-restart",
|
||||||
|
"unclean_shutdown_marker": true,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 12},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"sets": 3,
|
||||||
|
"pools": 3,
|
||||||
|
"outage_target_manifest_required": false,
|
||||||
|
"scope": "Target process crash during partial background rebuild on three single-node EC8+4 pools; exact baseline S3 bodies and replacement-drive shards, with outage object verified through S3 but not forced onto the replaced target drive."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"release_pending": {
|
"release_lanes": {
|
||||||
"G01": "W02/W04 complete root and quota authority coverage",
|
"single-set-restart": {
|
||||||
"G02": "W03 bounded checkpoint progress and independent version inventory",
|
"status": "implemented",
|
||||||
"G03": "W17/W18 exact scoped ACK with durable publication and mixed peers",
|
"cases": ["background-target-restart", "background-target-crash"],
|
||||||
"G04": "W03/W15/W16 crash at every cache/root/floor/intent boundary",
|
"covers": ["four-node one-drive topology", "unversioned objects", "target restart/crash"]
|
||||||
"G05": "W06/W07 per-object outcomes and bounded terminal retention",
|
},
|
||||||
"G06": "W06/W08/W23 concurrent status, legacy clients and truncation",
|
"authority-coverage": {
|
||||||
"G07": "W12/W13/W14 durable MRF responsibility at every commit boundary",
|
"status": "pending",
|
||||||
"G08": "W12/W13/W14 MRF capacity, disk-full and replica-loss matrix",
|
"gates": ["G01", "G12"],
|
||||||
"G09": "W13/W18/W23 actual mixed-version reader/writer and rollback payloads",
|
"requires": ["root authority coverage", "quota authority coverage"]
|
||||||
"G10": "W05/W09/W10/W11 bounded scheduling and pressure recovery",
|
},
|
||||||
"G11": "W04/W19/W24 maintenance and complete producer coverage",
|
"checkpoint-and-crash": {
|
||||||
"G12": "W02/W15/W16 both quota paths during reset and settlement",
|
"status": "pending",
|
||||||
"G13": "W07/W14 quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
|
"gates": ["G02", "G04", "R-E"],
|
||||||
"G14": "W20/W21 same-window field evidence; 3x4 EC8+4 and multi-set/pool coverage",
|
"requires": ["bounded checkpoint progress", "boundary crash matrix", "fixed-budget restart evidence"]
|
||||||
"P1": "W20 measured cold-walk share and foreground latency/throughput",
|
},
|
||||||
"P2": "W20/W24 measured post-stop convergence and cold segment reuse",
|
"status-and-outcome": {
|
||||||
"P3": "W20 measured two-hour pressure/heal capacity and recovery window",
|
"status": "pending",
|
||||||
"P4": "W20 measured MRF scale and replay cost with retained responsibility",
|
"gates": ["G05", "G06", "R-D"],
|
||||||
"R-E": "W03/W05 fixed-budget real process restart through enumeration and classification",
|
"requires": ["per-object outcomes", "legacy status clients", "manager/event/ledger disposition"]
|
||||||
"R-D": "W07/W14 manager-to-event-to-ledger exact disposition, including grace",
|
},
|
||||||
"R-L": "W13/W14 legacy source conflicts, migration gaps and crash-safe source retirement"
|
"mrf-responsibility": {
|
||||||
}
|
"status": "pending",
|
||||||
|
"gates": ["G07", "G08", "P4"],
|
||||||
|
"requires": ["durable MRF responsibility", "disk-full and replica-loss matrix", "MRF replay cost"]
|
||||||
|
},
|
||||||
|
"mixed-version-rollback": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G03", "G09", "R-L"],
|
||||||
|
"requires": ["mixed-version peers", "rollback payloads", "crash-safe source retirement"]
|
||||||
|
},
|
||||||
|
"scheduler-pressure": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G10", "P1", "P2", "P3"],
|
||||||
|
"requires": ["bounded scheduling", "foreground latency and throughput", "two-hour pressure evidence"]
|
||||||
|
},
|
||||||
|
"maintenance-producers": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G11", "G13"],
|
||||||
|
"requires": ["complete producer coverage", "quorum-minus-one and remount matrix"]
|
||||||
|
},
|
||||||
|
"ec8-4-multiset": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G14"],
|
||||||
|
"requires": ["3x4 EC8+4 topology", "multi-set coverage", "multi-pool coverage"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release_requirements": [
|
||||||
|
{
|
||||||
|
"gate": "G01",
|
||||||
|
"task": "W02/W04",
|
||||||
|
"lane": "authority-coverage",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Complete root and quota authority coverage",
|
||||||
|
"requires": ["root authority evidence", "quota authority evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"root_authority_evidence",
|
||||||
|
"quota_authority_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G02",
|
||||||
|
"task": "W03",
|
||||||
|
"lane": "checkpoint-and-crash",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Bounded checkpoint progress and independent version inventory",
|
||||||
|
"requires": ["bounded checkpoint oracle", "independent version inventory"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"bounded_checkpoint_oracle",
|
||||||
|
"independent_version_inventory"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G03",
|
||||||
|
"task": "W17/W18",
|
||||||
|
"lane": "mixed-version-rollback",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Exact scoped ACK with durable publication and mixed peers",
|
||||||
|
"requires": ["durable scoped ACK publication", "mixed-peer evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"durable_root_publication_proof",
|
||||||
|
"scoped_ack_request_identity",
|
||||||
|
"participating_peer_capability_snapshot",
|
||||||
|
"mixed_peer_ack_fallback_oracle"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G04",
|
||||||
|
"task": "W03/W15/W16",
|
||||||
|
"lane": "checkpoint-and-crash",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Crash at every cache, root, floor and intent boundary",
|
||||||
|
"requires": ["cache boundary crash evidence", "root/floor/intent crash evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"cache_boundary_crash_evidence",
|
||||||
|
"root_floor_intent_crash_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G05",
|
||||||
|
"task": "W06/W07",
|
||||||
|
"lane": "status-and-outcome",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Per-object outcomes and bounded terminal retention",
|
||||||
|
"requires": ["per-object outcome oracle", "terminal retention bounds"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"per_object_outcome_oracle",
|
||||||
|
"terminal_retention_bounds"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G06",
|
||||||
|
"task": "W06/W08/W23",
|
||||||
|
"lane": "status-and-outcome",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Concurrent status, legacy clients and truncation",
|
||||||
|
"requires": ["concurrent status evidence", "legacy client compatibility", "truncation behavior"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"concurrent_status_evidence",
|
||||||
|
"legacy_client_compatibility",
|
||||||
|
"truncation_behavior"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G07",
|
||||||
|
"task": "W12/W13/W14",
|
||||||
|
"lane": "mrf-responsibility",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Durable MRF responsibility at every commit boundary",
|
||||||
|
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mrf_responsibility_oracle",
|
||||||
|
"commit_boundary_crash_matrix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G08",
|
||||||
|
"task": "W12/W13/W14",
|
||||||
|
"lane": "mrf-responsibility",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "MRF capacity, disk-full and replica-loss matrix",
|
||||||
|
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mrf_capacity_evidence",
|
||||||
|
"disk_full_matrix",
|
||||||
|
"replica_loss_matrix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G09",
|
||||||
|
"task": "W13/W18/W23",
|
||||||
|
"lane": "mixed-version-rollback",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Actual mixed-version reader/writer and rollback payloads",
|
||||||
|
"requires": ["mixed-version reader evidence", "mixed-version writer evidence", "rollback payload evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed_version_writer_evidence",
|
||||||
|
"rollback_payload_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G10",
|
||||||
|
"task": "W05/W09/W10/W11",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Bounded scheduling and pressure recovery",
|
||||||
|
"requires": ["scheduler bound evidence", "pressure recovery evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"scheduler_bound_evidence",
|
||||||
|
"pressure_recovery_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G11",
|
||||||
|
"task": "W04/W19/W24",
|
||||||
|
"lane": "maintenance-producers",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Maintenance and complete producer coverage",
|
||||||
|
"requires": [
|
||||||
|
"maintenance producer matrix",
|
||||||
|
"complete producer inventory",
|
||||||
|
"segment activation preflight"
|
||||||
|
],
|
||||||
|
"evidence_fields": [
|
||||||
|
"maintenance_producer_matrix",
|
||||||
|
"complete_producer_inventory",
|
||||||
|
"segment_activation_preflight"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G12",
|
||||||
|
"task": "W02/W15/W16",
|
||||||
|
"lane": "authority-coverage",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Both quota paths during reset and settlement",
|
||||||
|
"requires": ["reset quota-path evidence", "settlement quota-path evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"reset_quota_path_evidence",
|
||||||
|
"settlement_quota_path_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G13",
|
||||||
|
"task": "W07/W14",
|
||||||
|
"lane": "maintenance-producers",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
|
||||||
|
"requires": ["quorum-minus-one matrix", "unknown-disk/remount matrix", "Object Lock dry-run grace evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"quorum_minus_one_matrix",
|
||||||
|
"unknown_disk_remount_matrix",
|
||||||
|
"object_lock_dry_run_grace_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W20/W21",
|
||||||
|
"lane": "ec8-4-multiset",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage",
|
||||||
|
"requires": [
|
||||||
|
"same-window field evidence",
|
||||||
|
"3x4 EC8+4 evidence",
|
||||||
|
"multi-set evidence",
|
||||||
|
"multi-pool evidence",
|
||||||
|
"distributed segment invalidation evidence"
|
||||||
|
],
|
||||||
|
"evidence_fields": [
|
||||||
|
"same_window_field_evidence",
|
||||||
|
"ec8_4_evidence",
|
||||||
|
"multi_set_evidence",
|
||||||
|
"multi_pool_evidence",
|
||||||
|
"distributed_segment_invalidation_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P1",
|
||||||
|
"task": "W20",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured cold-walk share and foreground latency/throughput",
|
||||||
|
"requires": ["cold-walk share measurement", "foreground latency/throughput measurement"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"cold_walk_share_measurement",
|
||||||
|
"foreground_latency_throughput_measurement",
|
||||||
|
"profile_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P2",
|
||||||
|
"task": "W20/W24",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured post-stop convergence and cold segment reuse",
|
||||||
|
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"post_stop_convergence_measurement",
|
||||||
|
"cold_segment_reuse_measurement"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P3",
|
||||||
|
"task": "W20",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured two-hour pressure/heal capacity and recovery window",
|
||||||
|
"requires": ["two-hour pressure measurement", "heal capacity measurement", "recovery-window measurement"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"two_hour_pressure_measurement",
|
||||||
|
"heal_capacity_measurement",
|
||||||
|
"recovery_window_measurement"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P4",
|
||||||
|
"task": "W20",
|
||||||
|
"lane": "mrf-responsibility",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured MRF scale and replay cost with retained responsibility",
|
||||||
|
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence", "cleanup/GC soak evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mrf_scale_measurement",
|
||||||
|
"mrf_replay_cost_measurement",
|
||||||
|
"retained_responsibility_evidence",
|
||||||
|
"mrf_cleanup_gc_soak_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "R-E",
|
||||||
|
"task": "W03/W05",
|
||||||
|
"lane": "checkpoint-and-crash",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Fixed-budget real process restart through enumeration and classification",
|
||||||
|
"requires": ["fixed-budget restart evidence", "enumeration evidence", "classification evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"fixed_budget_restart_evidence",
|
||||||
|
"enumeration_evidence",
|
||||||
|
"classification_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "R-D",
|
||||||
|
"task": "W07/W14",
|
||||||
|
"lane": "status-and-outcome",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Manager-to-event-to-ledger exact disposition, including grace",
|
||||||
|
"requires": ["manager disposition evidence", "event disposition evidence", "ledger disposition evidence", "grace handling"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"manager_disposition_evidence",
|
||||||
|
"event_disposition_evidence",
|
||||||
|
"ledger_disposition_evidence",
|
||||||
|
"grace_handling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "R-L",
|
||||||
|
"task": "W13/W14",
|
||||||
|
"lane": "mixed-version-rollback",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Legacy source conflicts, migration gaps and crash-safe source retirement",
|
||||||
|
"requires": ["legacy source-conflict evidence", "migration-gap evidence", "crash-safe source retirement evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"legacy_source_conflict_evidence",
|
||||||
|
"migration_gap_evidence",
|
||||||
|
"crash_safe_source_retirement_evidence"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
@@ -89,6 +90,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
@@ -135,6 +137,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
@@ -181,6 +184,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
@@ -72,6 +73,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
@@ -118,6 +120,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
@@ -164,6 +167,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
@@ -220,6 +221,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
@@ -239,6 +241,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
@@ -258,6 +261,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ Three pre-built Grafana dashboards are included for monitoring RustFS GET perfor
|
|||||||
| **GET Resource Impact** | `grafana-get-resource-impact.json` | Monitors resource usage: concurrent requests, IO queue utilization, disk permit wait, RSS trend |
|
| **GET Resource Impact** | `grafana-get-resource-impact.json` | Monitors resource usage: concurrent requests, IO queue utilization, disk permit wait, RSS trend |
|
||||||
| **Object Data Cache** | `grafana-object-data-cache.json` | Monitors the GET body cache (`rustfs_object_data_cache_*`): hit ratio, lookup/plan/fill outcomes, fill duration quantiles, hit vs fill throughput, entries/weighted bytes, inflight fills, memory-pressure skips, invalidations, and size-class breakdowns |
|
| **Object Data Cache** | `grafana-object-data-cache.json` | Monitors the GET body cache (`rustfs_object_data_cache_*`): hit ratio, lookup/plan/fill outcomes, fill duration quantiles, hit vs fill throughput, entries/weighted bytes, inflight fills, memory-pressure skips, invalidations, and size-class breakdowns |
|
||||||
|
|
||||||
|
### Storage Metrics
|
||||||
|
|
||||||
|
Storage panels require `prometheus-rules/rustfs-storage.yml` and the cluster resource attribute. See the [storage metrics guide](../../docs/operations/storage-metrics.md) for ownership, observer selection, freshness, and rolling upgrades.
|
||||||
|
|
||||||
### Prometheus Alert Rules
|
### Prometheus Alert Rules
|
||||||
|
|
||||||
The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-configured alerting rules:
|
The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-configured alerting rules:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
- RUSTFS_SECRET_KEY=rustfsadmin
|
- RUSTFS_SECRET_KEY=rustfsadmin
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=info
|
- RUSTFS_OBS_LOGGER_LEVEL=info
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040
|
- RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040
|
||||||
volumes:
|
volumes:
|
||||||
- rustfs-data:/data/rustfs
|
- rustfs-data:/data/rustfs
|
||||||
|
|||||||
@@ -223,14 +223,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_buckets_total{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_buckets_total\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "__auto",
|
"legendFormat": "__auto",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Total Buckets",
|
"title": "Total Buckets",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -289,14 +290,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_objects_total{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_objects_total\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "__auto",
|
"legendFormat": "__auto",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Total Objects",
|
"title": "Total Objects",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -427,7 +429,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_used_bytes{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_used_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "Used",
|
"legendFormat": "Used",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -438,7 +440,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_raw_total_bytes{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_raw_total_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"legendFormat": "Total",
|
"legendFormat": "Total",
|
||||||
"range": true,
|
"range": true,
|
||||||
@@ -450,7 +452,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_used_bytes{job=~\"$job\"}) / sum(rustfs_cluster_capacity_raw_total_bytes{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_used_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"} / ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_raw_total_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"instant": false,
|
"instant": false,
|
||||||
"legendFormat": "Percent",
|
"legendFormat": "Percent",
|
||||||
@@ -459,7 +461,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Capacity",
|
"title": "Capacity",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -525,7 +528,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_stale_drives{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_stale_drives\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "Stale Drives",
|
"legendFormat": "Stale Drives",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -536,14 +539,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_missing_drives{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_missing_drives\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "Missing Drives",
|
"legendFormat": "Missing Drives",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Capacity Observation",
|
"title": "Capacity Observation",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -1989,8 +1993,8 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (drive) (rustfs_system_drive_used_bytes{job=~\"$job\", drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{drive}} (bytes)",
|
"legendFormat": "{{server}} | {{drive}} (bytes)",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
@@ -2000,16 +2004,17 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (drive) (rustfs_system_drive_used_bytes{job=~\"$job\", drive=~\"$drive\"}) / sum by (drive)(rustfs_system_drive_total_bytes{job=~\"$job\", drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"} / ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_system_drive_total_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"instant": false,
|
"instant": false,
|
||||||
"legendFormat": "{{drive}} (percent)",
|
"legendFormat": "{{server}} | {{drive}} (percent)",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "System Drive Usage",
|
"title": "System Drive Usage",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -2097,14 +2102,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_age_seconds{job=~\"$job\", drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_age_seconds\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{drive}}",
|
"legendFormat": "{{server}} | {{drive}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive Capacity Observation Age",
|
"title": "Drive Capacity Observation Age",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -2190,8 +2196,8 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_state{job=~\"$job\", drive=~\"$drive\", state=\"stale\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_state\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",state=\"stale\"}",
|
||||||
"legendFormat": "{{drive}} stale",
|
"legendFormat": "{{server}} | {{drive}} stale",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
@@ -2201,14 +2207,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_state{job=~\"$job\", drive=~\"$drive\", state=\"missing\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_state\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",state=\"missing\"}",
|
||||||
"legendFormat": "{{drive}} missing",
|
"legendFormat": "{{server}} | {{drive}} missing",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive Capacity Observation State",
|
"title": "Drive Capacity Observation State",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -4551,7 +4558,7 @@
|
|||||||
"index": 0,
|
"index": 0,
|
||||||
"text": "INACTIVE"
|
"text": "INACTIVE"
|
||||||
},
|
},
|
||||||
"to": 1e-9
|
"to": 1e-09
|
||||||
},
|
},
|
||||||
"type": "range"
|
"type": "range"
|
||||||
}
|
}
|
||||||
@@ -6563,7 +6570,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "rustfs_cluster_health_drives_online_count{job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_online_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "online - {{job}}",
|
"legendFormat": "online - {{job}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -6574,7 +6581,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "rustfs_cluster_health_drives_offline_count{job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_offline_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "offline - {{job}}",
|
"legendFormat": "offline - {{job}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
@@ -6585,14 +6592,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "rustfs_cluster_health_drives_count{job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "total - {{job}}",
|
"legendFormat": "total - {{job}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "C"
|
"refId": "C"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Cluster Drive Health Counts",
|
"title": "Cluster Drive Health Counts",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"collapsed": false,
|
"collapsed": false,
|
||||||
@@ -8554,14 +8562,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "{__name__=~\"rustfs_system_drive_.*\",job=~\"$job\",drive=~\"$drive\"}",
|
"expr": "rustfs:storage:current{source_metric=~\"rustfs_system_drive_.*\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{__name__}} | {{drive}}",
|
"legendFormat": "{{server}} | {{source_metric}} | {{drive}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "System Drive (All)",
|
"title": "System Drive (All)",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -8942,14 +8951,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "{__name__=~\"rustfs_cluster_erasure_set_.*\",job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=~\"rustfs_cluster_erasure_set_.*\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "{{__name__}}",
|
"legendFormat": "{{source_metric}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Cluster Erasure Set (All)",
|
"title": "Cluster Erasure Set (All)",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -11716,7 +11726,7 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A",
|
"refId": "A",
|
||||||
"expr": "max by (server, drive, pool_index, set_index, drive_index, state) (rustfs_system_drive_runtime_state{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_drive_runtime_state\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{state}}"
|
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{state}}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -11727,12 +11737,13 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
"expr": "max by (server, drive, pool_index, set_index, drive_index) (rustfs_system_drive_offline_duration_seconds{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_drive_offline_duration_seconds\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{server}} | {{drive}} | offline seconds"
|
"legendFormat": "{{server}} | {{drive}} | offline seconds"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive Runtime State and Offline Duration",
|
"title": "Observed Cluster Drive State and Offline Duration",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -11825,12 +11836,13 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A",
|
"refId": "A",
|
||||||
"expr": "sum by (server, drive, pool_index, set_index, drive_index, api) (rate(rustfs_system_drive_api_calls_total{job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}[$__rate_interval]))",
|
"expr": "rate(rustfs_system_drive_api_calls_total{collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}[$__rate_interval]) and ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}",
|
||||||
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{api}}"
|
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{api}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive API Calls by Operation",
|
"title": "Drive API Calls by Operation",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -12376,6 +12388,50 @@
|
|||||||
"sort": 1,
|
"sort": 1,
|
||||||
"type": "query"
|
"type": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(rustfs:storage_snapshot:fresh, rustfs_cluster_id)",
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Storage cluster",
|
||||||
|
"multi": false,
|
||||||
|
"name": "storage_cluster",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values(rustfs:storage_snapshot:fresh, rustfs_cluster_id)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-storage_cluster"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "query_result(rustfs:storage_snapshot:fresh{collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\"})",
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Cluster observer",
|
||||||
|
"multi": false,
|
||||||
|
"name": "storage_observer",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 3,
|
||||||
|
"query": "query_result(rustfs:storage_snapshot:fresh{collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\"})",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-storage_observer"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "/observer=\"([^\"]+)\"/",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"allValue": ".*",
|
"allValue": ".*",
|
||||||
"current": {
|
"current": {
|
||||||
@@ -12386,7 +12442,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"definition": "label_values(rustfs_system_drive_api_calls_total,api)",
|
"definition": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, api)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"label": "Drive API",
|
"label": "Drive API",
|
||||||
"multi": true,
|
"multi": true,
|
||||||
@@ -12394,7 +12450,7 @@
|
|||||||
"options": [],
|
"options": [],
|
||||||
"query": {
|
"query": {
|
||||||
"qryType": 1,
|
"qryType": 1,
|
||||||
"query": "label_values(rustfs_system_drive_api_calls_total,api)",
|
"query": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, api)",
|
||||||
"refId": "PrometheusVariableQueryEditor-drive_api"
|
"refId": "PrometheusVariableQueryEditor-drive_api"
|
||||||
},
|
},
|
||||||
"refresh": 2,
|
"refresh": 2,
|
||||||
@@ -12455,7 +12511,7 @@
|
|||||||
"text": "All",
|
"text": "All",
|
||||||
"value": "$__all"
|
"value": "$__all"
|
||||||
},
|
},
|
||||||
"definition": "label_values(rustfs_system_drive_used_bytes,drive)",
|
"definition": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, drive)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"label": "Drive",
|
"label": "Drive",
|
||||||
"multi": true,
|
"multi": true,
|
||||||
@@ -12463,7 +12519,7 @@
|
|||||||
"options": [],
|
"options": [],
|
||||||
"query": {
|
"query": {
|
||||||
"qryType": 1,
|
"qryType": 1,
|
||||||
"query": "label_values(rustfs_system_drive_used_bytes,drive)",
|
"query": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, drive)",
|
||||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||||
},
|
},
|
||||||
"refresh": 2,
|
"refresh": 2,
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
# Preserve OTLP timestamps in the Collector. Apply timestamp() directly to
|
||||||
|
# each raw selector, before label rewriting; functions such as label_replace()
|
||||||
|
# replace the evaluation timestamp and would make a cached value look new.
|
||||||
|
# Compare publication times at Prometheus millisecond precision so points
|
||||||
|
# published and exported within the same millisecond are not withheld.
|
||||||
|
groups:
|
||||||
|
- name: rustfs-storage-snapshots
|
||||||
|
interval: 15s
|
||||||
|
rules:
|
||||||
|
- record: rustfs:storage_snapshot:fresh
|
||||||
|
expr: |
|
||||||
|
rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}
|
||||||
|
and ((time() - rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}) <= rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"})
|
||||||
|
and ((time() - rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}) >= 0)
|
||||||
|
and (rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"} > 0)
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_buckets_total
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_buckets_total{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_buckets_total{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_missing_drives
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_missing_drives{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_missing_drives{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_raw_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_stale_drives
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_stale_drives{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_stale_drives{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_usable_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_usable_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_usable_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_capacity_observation_age_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_capacity_observation_state
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_offline_duration_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_present
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_present{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_present{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_runtime_state
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_runtime_state{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_runtime_state{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_data_shards
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_data_shards{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_data_shards{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_healing_drives_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_healing_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_healing_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_online_drives_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_online_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_online_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_overall_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_overall_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_overall_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_overall_write_quorum
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_overall_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_overall_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_parity
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_parity{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_parity{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_read_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_read_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_read_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_read_quorum
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_read_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_read_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_read_tolerance
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_read_tolerance{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_read_tolerance{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_size
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_size{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_size{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_write_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_write_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_write_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_write_quorum
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_write_tolerance
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_write_tolerance{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_write_tolerance{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_health_drives_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_health_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_health_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_health_drives_offline_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_health_drives_offline_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_health_drives_offline_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_health_drives_online_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_health_drives_online_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_health_drives_online_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_objects_total
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_objects_total{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_objects_total{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_node_disk_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_node_disk_free_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_node_disk_free_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_node_disk_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_node_disk_total_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_node_disk_total_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_node_disk_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_node_disk_used_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_node_disk_used_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_api_calls_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_api_calls_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_api_calls_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_api_latency_by_api_micros
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_api_latency_by_api_micros{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_api_latency_by_api_micros{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_api_latency_micros
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_api_latency_micros{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_api_latency_micros{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_availability_errors_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_availability_errors_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_availability_errors_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_capacity_observation_age_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_capacity_observation_state
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_count
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_count{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_count{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_deletes_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_deletes_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_deletes_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_free_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_free_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_free_inodes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_free_inodes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_free_inodes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_healing
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_healing{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_healing{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_health
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_health{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_health{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_info
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_info{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_info{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_io_errors_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_io_errors_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_io_errors_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_offline_count
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_offline_count{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_offline_count{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_offline_duration_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_online_count
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_online_count{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_online_count{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_perc_util
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_perc_util{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_perc_util{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_present
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_present{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_present{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_reads_await
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_reads_await{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_reads_await{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_reads_kb_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_reads_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_reads_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_reads_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_reads_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_reads_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_runtime_state
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_runtime_state{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_runtime_state{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_scanning
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_scanning{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_scanning{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_timeout_errors_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_timeout_errors_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_timeout_errors_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_total_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_total_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_total_inodes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_total_inodes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_total_inodes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_used_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_used_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_used_inodes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_used_inodes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_used_inodes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_waiting_io
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_waiting_io{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_waiting_io{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_await
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_await{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_await{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_kb_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
rule_files:
|
||||||
|
- ../prometheus-rules/rustfs-storage.yml
|
||||||
|
evaluation_interval: 15s
|
||||||
|
tests:
|
||||||
|
- name: owners, pools, clusters and observer views remain distinct in mixed versions
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",pool_index="0",set_index="0",drive_index="0"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",pool_index="0",set_index="0",drive_index="1"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector",server="n2",drive="/data",pool_index="1",set_index="0",drive_index="0"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector",server="n3",drive="/data",pool_index="1",set_index="0",drive_index="1"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",pool_index="0",set_index="0",drive_index="0"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",pool_index="0",set_index="0",drive_index="1"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector",server="n2",drive="/data",pool_index="1",set_index="0",drive_index="0"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector",server="n3",drive="/data",pool_index="1",set_index="0",drive_index="1"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 400x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 300x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="old",server="n0",drive="/data",job="rustfs",instance="collector"}
|
||||||
|
values: 999x8
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: sum by (rustfs_cluster_id) (rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes",collection_scope="local"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{rustfs_cluster_id="a"}'
|
||||||
|
value: 400
|
||||||
|
- labels: '{rustfs_cluster_id="b"}'
|
||||||
|
value: 4000
|
||||||
|
- expr: count by (rustfs_cluster_id,pool_index) (rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{rustfs_cluster_id="a",pool_index="0"}'
|
||||||
|
value: 2
|
||||||
|
- labels: '{rustfs_cluster_id="a",pool_index="1"}'
|
||||||
|
value: 2
|
||||||
|
- labels: '{rustfs_cluster_id="b",pool_index="0"}'
|
||||||
|
value: 2
|
||||||
|
- labels: '{rustfs_cluster_id="b",pool_index="1"}'
|
||||||
|
value: 2
|
||||||
|
- expr: sum(rustfs:storage:current{source_metric="rustfs_cluster_capacity_raw_total_bytes",rustfs_cluster_id="a",observer="n0"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 400
|
||||||
|
- expr: sum(rustfs:storage:current{source_metric="rustfs_cluster_capacity_raw_total_bytes",rustfs_cluster_id="a",observer="n1"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 300
|
||||||
|
- name: removed values and identities cannot rejoin new snapshots; stalled sources expire
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0 60 _ _ _ _ _ _ _
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_info{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="old"}
|
||||||
|
values: 1 1 _ _ _ _ _ _ _
|
||||||
|
- series: rustfs_system_drive_info{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="new"}
|
||||||
|
values: _ _ 1 1 1 1 1 1 1
|
||||||
|
- series: rustfs_system_drive_waiting_io{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data"}
|
||||||
|
values: 7 7 _ _ _ _ _ _ _
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data"}
|
||||||
|
values: 100 100 _ _ _ _ _ _ _
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_info",disk_id="old"}
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples: []
|
||||||
|
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_waiting_io"}
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples: []
|
||||||
|
- expr: count(rustfs:storage:current{source_metric="rustfs_system_drive_info",disk_id="new"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 1
|
||||||
|
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes"}
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples: []
|
||||||
|
- expr: rustfs:storage_snapshot:fresh{observer="n1"}
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples: []
|
||||||
|
- name: counters reset independently; filter versions before rate and sum rates across owners
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="n0-disk",api="read_all"}
|
||||||
|
values: 0 60 120 30 90 150 210
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",disk_id="n1-disk",api="read_all"}
|
||||||
|
values: 0+120x6
|
||||||
|
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",server="n0",drive="/data",observer="old"}
|
||||||
|
values: 0+999x6
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: sum(resets(rustfs_system_drive_api_calls_total{collection_scope="local"}[5m]))
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 1
|
||||||
|
- expr: sum(rate(rustfs_system_drive_api_calls_total{collection_scope="local"}[2m]) and ignoring(source_metric) rustfs:storage:current{source_metric="rustfs_system_drive_api_calls_total"})
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 3
|
||||||
|
- name: completed slow collection has no validity budget
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="slow",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x3
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="slow",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: '0x3'
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: rustfs:storage_snapshot:fresh{observer="slow"}
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples: []
|
||||||
|
- name: submillisecond publication and millisecond OTLP samples share a cutoff
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0.0009+60x3
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x3
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector",server="submillisecond",drive="/data"}
|
||||||
|
values: 100x3
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: count(rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes",observer="submillisecond"})
|
||||||
|
eval_time: 2m15s
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 1
|
||||||
@@ -111,10 +111,6 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
run: ./scripts/check_no_planning_docs.sh
|
||||||
|
|
||||||
- name: Check CI paths stay in sync
|
|
||||||
shell: bash
|
|
||||||
run: ./scripts/check_ci_paths_sync.sh
|
|
||||||
|
|
||||||
- name: Check io_uring lane --lib precondition
|
- name: Check io_uring lane --lib precondition
|
||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
run: ./scripts/check_uring_lane_lib_only.sh
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ secret_key = ${S3_SECRET_KEY}
|
|||||||
|
|
||||||
## replace with key id obtained when secret is created, or delete if KMS not tested
|
## replace with key id obtained when secret is created, or delete if KMS not tested
|
||||||
#kms_keyid = 01234567-89ab-cdef-0123-456789abcdef
|
#kms_keyid = 01234567-89ab-cdef-0123-456789abcdef
|
||||||
|
#kms_keyid2 = fedcba98-7654-3210-fedc-ba9876543210
|
||||||
|
|
||||||
|
## Expected service default for SSE-KMS requests without a key id; empty means none
|
||||||
|
#kms_default_keyid =
|
||||||
|
|
||||||
## Storage classes
|
## Storage classes
|
||||||
#storage_classes = "LUKEWARM, FROZEN"
|
#storage_classes = "LUKEWARM, FROZEN"
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
# Reports the existing required checks for paths excluded by ci.yml.
|
|
||||||
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
|
||||||
# action to keep validation coverage aligned. Keep this paths list in sync with
|
|
||||||
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
|
||||||
|
|
||||||
name: Continuous Integration (docs only)
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [ opened, synchronize, reopened ]
|
|
||||||
branches: [ main, release ]
|
|
||||||
paths:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quick-checks:
|
|
||||||
name: Quick Checks
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Run shared quick checks
|
|
||||||
uses: ./.github/actions/quick-checks
|
|
||||||
|
|
||||||
test-and-lint:
|
|
||||||
name: Test and Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
|
||||||
# planning-type document could be slipped in (git add -f bypasses
|
|
||||||
# .gitignore). Run the guard here so the required "Test and Lint" check
|
|
||||||
# stays meaningful for docs-only changes.
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Satisfy required check for docs-only changes
|
|
||||||
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
|
|
||||||
+77
-74
@@ -37,25 +37,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main, release ]
|
branches: [ main, release ]
|
||||||
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
|
|
||||||
# reports the required "Test and Lint" check for PRs skipped here.
|
|
||||||
paths-ignore:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
merge_group:
|
merge_group:
|
||||||
types: [ checks_requested ]
|
types: [ checks_requested ]
|
||||||
schedule:
|
schedule:
|
||||||
@@ -88,6 +69,32 @@ jobs:
|
|||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
|
|
||||||
|
classify-changes:
|
||||||
|
name: Select CI scope
|
||||||
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
mode: ${{ steps.scope.outputs.mode }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Select scope using the base revision's policy
|
||||||
|
id: scope
|
||||||
|
env:
|
||||||
|
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
|
||||||
|
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
|
||||||
|
else
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Base CI policy unavailable; running the full matrix."
|
||||||
|
fi
|
||||||
|
|
||||||
typos:
|
typos:
|
||||||
name: Typos
|
name: Typos
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -100,7 +107,7 @@ jobs:
|
|||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fail early with compile-free checks shared with docs-only CI.
|
# Fail early with compile-free checks for every pull request.
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -116,9 +123,9 @@ jobs:
|
|||||||
uses: ./.github/actions/quick-checks
|
uses: ./.github/actions/quick-checks
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Workspace Test and Lint
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -289,45 +296,6 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
# Record the reason before this job completes as FAILURE. A separate
|
|
||||||
# dependent job cancels sibling lanes only after GitHub has preserved this
|
|
||||||
# required check's failure verdict.
|
|
||||||
- name: Annotate early-stop reason
|
|
||||||
if: >-
|
|
||||||
failure() && github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "## CI early-stop"
|
|
||||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
|
||||||
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
|
||||||
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
|
||||||
# CANCELLED and hid the actionable failure in the PR checks UI.
|
|
||||||
cancel-after-test-and-lint-failure:
|
|
||||||
name: Cancel siblings after Test and Lint failure
|
|
||||||
if: >-
|
|
||||||
failure() && needs.test-and-lint.result == 'failure'
|
|
||||||
&& github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
needs: [ test-and-lint ]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
actions: write
|
|
||||||
steps:
|
|
||||||
- name: Cancel remaining jobs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||||
@@ -340,8 +308,8 @@ jobs:
|
|||||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||||
test-ilm-integration-serial:
|
test-ilm-integration-serial:
|
||||||
name: ILM Integration (serial)
|
name: ILM Integration (serial)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -408,8 +376,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-rio-v2:
|
test-and-lint-rio-v2:
|
||||||
name: Test and Lint (rio-v2)
|
name: Test and Lint (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -449,8 +417,8 @@ jobs:
|
|||||||
|
|
||||||
connect-short-credential-boundary:
|
connect-short-credential-boundary:
|
||||||
name: Connect Short Credential Boundary
|
name: Connect Short Credential Boundary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -507,8 +475,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-protocols:
|
test-and-lint-protocols:
|
||||||
name: "Test and Lint (${{ matrix.features.name }})"
|
name: "Test and Lint (${{ matrix.features.name }})"
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
strategy:
|
strategy:
|
||||||
@@ -561,8 +529,8 @@ jobs:
|
|||||||
|
|
||||||
build-rustfs-debug-binary:
|
build-rustfs-debug-binary:
|
||||||
name: Build RustFS Debug Binary
|
name: Build RustFS Debug Binary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -684,8 +652,8 @@ jobs:
|
|||||||
# job had neither, so each closed/merged PR really ran the whole io_uring
|
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||||
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||||
# 30662728539) and kept the cancellation run in progress for minutes.
|
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||||
@@ -1213,9 +1181,44 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
required-checks:
|
||||||
|
name: Test and Lint
|
||||||
|
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- typos
|
||||||
|
- quick-checks
|
||||||
|
- test-and-lint
|
||||||
|
- test-ilm-integration-serial
|
||||||
|
- test-and-lint-rio-v2
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- test-and-lint-protocols
|
||||||
|
- build-rustfs-debug-binary
|
||||||
|
- uring-integration
|
||||||
|
- e2e-tests
|
||||||
|
- s3-implemented-tests
|
||||||
|
- s3-lifecycle-behavior-tests
|
||||||
|
- build-rustfs-debug-binary-rio-v2
|
||||||
|
- e2e-tests-rio-v2
|
||||||
|
- e2e-full
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Require the expected result of every CI lane
|
||||||
|
env:
|
||||||
|
CI_NEEDS: ${{ toJSON(needs) }}
|
||||||
|
shell: bash
|
||||||
|
run: python3 scripts/ci_gate.py verify
|
||||||
|
|
||||||
alert-on-failure:
|
alert-on-failure:
|
||||||
name: Alert on scheduled failure
|
name: Alert on scheduled failure
|
||||||
needs:
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- required-checks
|
||||||
- typos
|
- typos
|
||||||
- quick-checks
|
- quick-checks
|
||||||
- test-and-lint
|
- test-and-lint
|
||||||
|
|||||||
@@ -129,11 +129,22 @@ jobs:
|
|||||||
: > target/debug/rustfs.features
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
- name: Run upgrade compatibility test
|
- name: Run upgrade compatibility test
|
||||||
|
env:
|
||||||
|
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --locked -p e2e_test \
|
cargo test --locked -p e2e_test \
|
||||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||||
-- --ignored --exact --nocapture
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
- name: Upload scanner/heal G09 evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
|
||||||
|
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: Upload server logs
|
- name: Upload server logs
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
|||||||
@@ -19,17 +19,27 @@ on:
|
|||||||
- cron: "7 0 * * *"
|
- cron: "7 0 * * *"
|
||||||
timezone: "Asia/Shanghai"
|
timezone: "Asia/Shanghai"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: 'Branch/ref to build and publish as the nightly (empty = scheduled source, see NIGHTLY_BUILD_REF)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# Scheduled builds follow the NIGHTLY_BRANCH repo variable so the channel can
|
||||||
|
# be pointed at e.g. `release` for the GA cycle and back to `main` afterwards
|
||||||
|
# without touching this file. Manual runs take the `branch` input, falling
|
||||||
|
# back to the branch the run was dispatched from.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: nightly-gnu-build-main-${{ github.event_name }}
|
group: nightly-gnu-build-${{ github.event_name }}-${{ github.event_name == 'schedule' && (vars.NIGHTLY_BRANCH || 'main') || (inputs.branch || github.ref_name) }}
|
||||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
|
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
|
NIGHTLY_BUILD_REF: ${{ github.event_name == 'schedule' && (vars.NIGHTLY_BRANCH || 'main') || (inputs.branch || github.ref_name) }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -43,6 +53,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -152,13 +163,104 @@ jobs:
|
|||||||
|
|
||||||
fakeroot dpkg-deb --build "${PKG_DIR}"
|
fakeroot dpkg-deb --build "${PKG_DIR}"
|
||||||
ls -lh "${DEB_FILE}"
|
ls -lh "${DEB_FILE}"
|
||||||
|
echo "deb_date=${DEB_DATE}" >> "${GITHUB_OUTPUT}"
|
||||||
echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}"
|
echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
# Same packaging scheme as .github/workflows/package.yml (fpm), but from
|
||||||
|
# the locally built nightly binary instead of a release artifact, with a
|
||||||
|
# date-based version that mirrors the DEB.
|
||||||
|
- name: Build RPM package
|
||||||
|
id: rpm
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v fpm >/dev/null 2>&1; then
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} apt-get update -qq && ${SUDO} apt-get install -y -qq ruby ruby-dev build-essential rpm >/dev/null
|
||||||
|
${SUDO} gem install fpm --no-document >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
RPM_FILE="rustfs-nightly-${DEB_DATE}.rpm"
|
||||||
|
RPM_VERSION="0"
|
||||||
|
RPM_RELEASE="0.nightly.${DEB_DATE//-/.}"
|
||||||
|
|
||||||
|
echo "Building RPM: ${RPM_FILE} (version ${RPM_VERSION}-${RPM_RELEASE})"
|
||||||
|
|
||||||
|
# fpm wants the config file to exist before packaging.
|
||||||
|
mkdir -p ./tmp-pkg/etc/default
|
||||||
|
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
|
||||||
|
# RustFS Environment Configuration
|
||||||
|
# See https://rustfs.com/docs/ for more information
|
||||||
|
# RUSTFS_VOLUMES=""
|
||||||
|
# RUSTFS_ROOT_USER=""
|
||||||
|
# RUSTFS_ROOT_PASSWORD=""
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
fpm -s dir -t rpm \
|
||||||
|
--name rustfs \
|
||||||
|
--version "$RPM_VERSION" \
|
||||||
|
--iteration "$RPM_RELEASE" \
|
||||||
|
--architecture x86_64 \
|
||||||
|
--package "$RPM_FILE" \
|
||||||
|
--depends "glibc >= 2.31" \
|
||||||
|
--maintainer "RustFS Team <support@rustfs.com>" \
|
||||||
|
--description "High-performance distributed object storage" \
|
||||||
|
--url "https://rustfs.com" \
|
||||||
|
--license "Apache-2.0" \
|
||||||
|
--after-install <(cat << 'POSTINST'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if ! getent passwd rustfs > /dev/null 2>&1; then
|
||||||
|
useradd -r -s /bin/false -d /opt/rustfs rustfs
|
||||||
|
fi
|
||||||
|
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
|
||||||
|
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
POSTINST
|
||||||
|
) \
|
||||||
|
--before-remove <(cat << 'PRERM'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
|
||||||
|
systemctl stop rustfs
|
||||||
|
fi
|
||||||
|
PRERM
|
||||||
|
) \
|
||||||
|
--after-remove <(cat << 'POSTRM'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
POSTRM
|
||||||
|
) \
|
||||||
|
--config-files /etc/default/rustfs \
|
||||||
|
"rustfs-nightly-${DEB_DATE}/usr/bin/rustfs=/usr/bin/rustfs" \
|
||||||
|
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
|
||||||
|
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
|
||||||
|
LICENSE=/usr/share/doc/rustfs/LICENSE \
|
||||||
|
README.md=/usr/share/doc/rustfs/README.md
|
||||||
|
|
||||||
|
[[ -f "$RPM_FILE" ]] || { echo "RPM build failed"; exit 1; }
|
||||||
|
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
|
||||||
|
stat --printf='%n %s bytes\n' "$RPM_FILE"
|
||||||
|
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Upload DEB artifact
|
- name: Upload DEB artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.deb.outputs.deb_file }}
|
name: ${{ steps.deb.outputs.deb_file }}
|
||||||
path: ${{ steps.deb.outputs.deb_file }}
|
path: ${{ steps.deb.outputs.deb_file }}
|
||||||
|
- name: Upload RPM artifact
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ${{ steps.rpm.outputs.rpm_file }}
|
||||||
|
path: ${{ steps.rpm.outputs.rpm_file }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Persist the nightly deb on Cloudflare R2 (same channel as package.yml)
|
# Persist the nightly deb on Cloudflare R2 (same channel as package.yml)
|
||||||
@@ -187,11 +289,10 @@ jobs:
|
|||||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||||
export AWS_DEFAULT_REGION="auto"
|
export AWS_DEFAULT_REGION="auto"
|
||||||
|
|
||||||
|
# The candidate manifest must describe the tree that was actually
|
||||||
|
# built. With a ref override (NIGHTLY_BRANCH / dispatch input) that
|
||||||
|
# is not necessarily GITHUB_SHA, so always advertise HEAD.
|
||||||
SOURCE_SHA="$(git rev-parse HEAD)"
|
SOURCE_SHA="$(git rev-parse HEAD)"
|
||||||
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
|
|
||||||
echo "Checkout SHA does not match the nightly build run" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
|
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
|
||||||
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
||||||
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
||||||
@@ -247,6 +348,20 @@ jobs:
|
|||||||
path: ${{ steps.publish.outputs.candidate_file }}
|
path: ${{ steps.publish.outputs.candidate_file }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Publish the deb/rpm pair to the auto-testing repo's `assets` branch so
|
||||||
|
# engineers can download and install the nightly directly. The branch is
|
||||||
|
# a single-commit orphan rewritten on every build, which keeps the repo
|
||||||
|
# small while the latest files stay reachable at stable raw URLs.
|
||||||
|
# Publish the deb/rpm pair as assets of the rolling `nightly` release on
|
||||||
|
# rustfs/auto-testing (see scripts/release/publish_nightly_assets.sh).
|
||||||
|
- name: Publish packages to auto-testing release assets
|
||||||
|
env:
|
||||||
|
ASSETS_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||||
|
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||||
|
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
|
||||||
|
BUILD_REF: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
run: bash scripts/release/publish_nightly_assets.sh
|
||||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||||
#
|
#
|
||||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||||
@@ -284,6 +399,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -372,6 +488,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ jobs:
|
|||||||
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
|
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
|
||||||
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
|
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
|
||||||
if [[ -n "$TAG" ]]; then
|
if [[ -n "$TAG" ]]; then
|
||||||
if [[ "$TAG" =~ ^${semver_core}(-${prerelease_id})?-preview\.(0|[1-9][0-9]*)$ ]]; then
|
if [[ "$TAG" =~ ^${semver_core}-${prerelease_id}-preview\.(0|[1-9][0-9]*)$ ]]; then
|
||||||
BUILD_TYPE=preview
|
BUILD_TYPE=preview
|
||||||
elif [[ "$TAG" =~ ^${semver_core}-${prerelease_id}$ ]]; then
|
elif [[ "$TAG" =~ ^${semver_core}-${prerelease_id}$ ]]; then
|
||||||
BUILD_TYPE=prerelease
|
BUILD_TYPE=prerelease
|
||||||
@@ -498,7 +498,7 @@ jobs:
|
|||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload packages to Cloudflare R2
|
- name: Upload packages to Cloudflare R2
|
||||||
if: env.R2_ACCESS_KEY_ID != '' && needs.resolve.outputs.build_type != 'preview'
|
if: env.R2_ACCESS_KEY_ID != ''
|
||||||
env:
|
env:
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ jobs:
|
|||||||
performance-test:
|
performance-test:
|
||||||
runs-on: pf-testing
|
runs-on: pf-testing
|
||||||
timeout-minutes: 900
|
timeout-minutes: 900
|
||||||
|
env:
|
||||||
|
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
|
||||||
|
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
|
||||||
|
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
|
||||||
|
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
|
||||||
|
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
|
||||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||||
# Skipped when nightly failed.
|
# Skipped when nightly failed.
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
@@ -158,19 +164,15 @@ jobs:
|
|||||||
- name: Run benchmark (GET/PUT/MIXED)
|
- name: Run benchmark (GET/PUT/MIXED)
|
||||||
id: benchmark
|
id: benchmark
|
||||||
run: |
|
run: |
|
||||||
# Empty on automatic (workflow_run) runs -> full 30 rounds.
|
|
||||||
# Manual dispatch can restrict method(s)/size(s).
|
|
||||||
export WARP_METHODS="${{ inputs.test_method }}"
|
|
||||||
export WARP_SIZES="${{ inputs.object_size }}"
|
|
||||||
./auto-testing/rustfs_performance_test.sh \
|
./auto-testing/rustfs_performance_test.sh \
|
||||||
--step 5 -y \
|
--step 5 -y \
|
||||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
|
||||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
|
||||||
--log-file "${LOG_FILE}"
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Analyze results
|
- name: Analyze results
|
||||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
run: |
|
run: |
|
||||||
|
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
|
||||||
|
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
|
||||||
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Collect RustFS version info
|
- name: Collect RustFS version info
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ name: Windows Filesystem Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths:
|
paths:
|
||||||
- "crates/ecstore/src/disk/**"
|
- "crates/ecstore/src/disk/**"
|
||||||
- "crates/ecstore/src/store/init_format.rs"
|
- "crates/ecstore/src/store/init_format.rs"
|
||||||
@@ -26,7 +26,7 @@ on:
|
|||||||
- ".github/actions/setup/**"
|
- ".github/actions/setup/**"
|
||||||
- ".github/workflows/windows-filesystem.yml"
|
- ".github/workflows/windows-filesystem.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths:
|
paths:
|
||||||
- "crates/ecstore/src/disk/**"
|
- "crates/ecstore/src/disk/**"
|
||||||
- "crates/ecstore/src/store/init_format.rs"
|
- "crates/ecstore/src/store/init_format.rs"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ docs
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
!docs/
|
!docs/
|
||||||
docs/*
|
docs/*
|
||||||
|
!docs/README.md
|
||||||
!docs/architecture/
|
!docs/architecture/
|
||||||
!docs/architecture/**
|
!docs/architecture/**
|
||||||
!docs/operations/
|
!docs/operations/
|
||||||
|
|||||||
@@ -7,10 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Replication
|
||||||
|
|
||||||
|
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
|
||||||
|
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
|
||||||
|
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
|
||||||
|
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
|
||||||
|
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
|
||||||
|
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
|
||||||
|
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
|
||||||
|
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
|
||||||
|
- **KMS failures on the S3 data path carry an actionable status**: only "key not found" and a backend outage were classified; every other KMS failure — a disabled or pending-deletion key, a denied KMS grant, an encryption-context mismatch, an unsupported algorithm, a credential or timeout failure, a capability the backend does not have — collapsed onto `500 InternalError`. SDKs therefore applied exponential backoff to configuration errors that no retry can fix, and monitoring filed every one of them as a server fault. Unusable-key and request-side failures now return `400`, a denied grant `403`, transient backend failures `503` — including a key store the backend could not read, so an outage stays distinguishable from a missing key all the way to the client — and a missing backend capability `501`. Damaged or unreadable key material still returns `500`, which is what it is.
|
||||||
|
- **SSE-C on buckets with default encryption**: a `PutObject` carrying a valid SSE-C header triple on a bucket that has default encryption configured no longer fails with `400 InvalidArgument` ("The SSE-C and managed server-side encryption headers cannot be used together"). PUT and the POST-object/extract path resolved the bucket default with a hard-coded "no explicit SSE-C" flag, so the default was layered onto the request and then tripped the request's own mutual-exclusion check; an SSE-C request now suppresses the bucket default on all three write paths, matching COPY and AWS S3. Every bucket with default encryption previously refused SSE-C single PUTs outright, while `CreateMultipartUpload` on the same bucket succeeded.
|
||||||
|
- **Explicit SSE-S3 on SSE-KMS-default buckets**: `x-amz-server-side-encryption: AES256` against a bucket whose default is `aws:kms` no longer fails with `400 InvalidArgument`. The bucket default's KMS key id was inherited independently of the effective algorithm, producing a self-contradictory `AES256` + key-id pair; the key id is now inherited only when the effective algorithm is `aws:kms`. `PutBucketEncryption` fills in a default key id automatically, so this affected nearly every SSE-KMS-default bucket.
|
||||||
|
- **Restore of encrypted or compressed multipart objects (silent data corruption)**: restoring a multipart object from a remote tier addressed the tier in *plaintext* coordinates while the copy-back reads the *stored* representation. Every part received a misaligned slice of the remote object whose length still satisfied the range, the hash reader and the completion size check, so the restore reported success and replaced the object's bytes. Restore now accumulates stored part sizes, passes the stored length to the hash reader alongside the plaintext length, and validates against the stored size. Objects restored by an affected release must be re-restored from the tier or recovered from a backup — this release does not detect or repair them retroactively.
|
||||||
|
- **Restore no longer drifts the object ETag**: the copy-back digests stored (encrypted or compressed) bytes, so the recomputed MD5 is not the object's public ETag. Single-part and multipart restores now preserve the original object ETag, and each restored part keeps its own recorded part ETag.
|
||||||
|
- **ILM archive no longer forwards encryption metadata to the tier**: transition requests carried the object's SSE headers and the RustFS-wrapped data key as request headers. Any S3 target rejected an SSE-C archive outright (`400`, no key supplied), an SSE-KMS archive asked the target to encrypt a second time under a key id it does not own, and the wrapped DEK left the cluster. The archive request now strips every SSE header and encryption marker using the same predicate the replication path uses; the local `xl.meta` keeps all of it, so read-through and restore are unaffected.
|
||||||
|
- **KMS reload is no longer a no-op on a node whose KMS failed to start**: `POST /rustfs/admin/v3/kms/reload` short-circuited whenever the persisted configuration matched the in-memory one byte for byte. A node whose KMS failed to start (for example Vault briefly unreachable during a rolling restart) keeps that configuration and sits in `Error`, so the documented recovery call returned "reloaded successfully" while leaving the node down — and did the same on every peer through the reload broadcast. Reload now short-circuits only for a service that is actually running, and otherwise reconfigures, which starts the service.
|
||||||
|
- **AWS KMS capability reporting**: the AWS backend no longer advertises `versioning` support through `GET /rustfs/admin/v3/kms/status`. AWS KMS key versions are not enumerable through this backend, as the backend documentation already stated.
|
||||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||||
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
|
||||||
@@ -52,6 +72,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
|
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
|
||||||
|
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
|
||||||
|
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
|
||||||
|
- **Per-key SSE-KMS authorization** (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY`, default `false`). With it on, anonymous callers hold no KMS grants, so **a public bucket serving SSE-KMS objects is an incompatible combination** and those reads return `AccessDenied`.
|
||||||
|
- Envelope context binding as KMS AAD (`ENV_KMS_ENVELOPE_AAD`, off by default; a node that predates the field cannot open bound envelopes).
|
||||||
|
- Vault custom CA and mutual TLS; object-level DEK rewrap plus a batch rekey admin API; a backend-locality runtime signal on `kms/status`.
|
||||||
|
- Single-pass decryption for encrypted GET, and encrypted single-part closed-range seek — the latter is now **on by default** (`RUSTFS_ENCRYPTED_RANGE_SEEK`, default `true`; the switch remains as a kill switch).
|
||||||
|
- **Vault static tokens are now tracked and renewed**: with `Token` authentication RustFS hard-coded "this token has no lease", so the renewal task never started and no remaining-TTL gauge was published. `vault token create` grants a 768-hour TTL by default, which turned a healthy-looking cluster into one where every KMS call returned 403 about a month later, with no self-healing short of a restart or reconfigure. RustFS now calls `auth/token/lookup-self` at login and adopts what Vault reports: a non-expiring token behaves exactly as before, an expiring renewable one is renewed at half TTL like the other auth methods, and an expiring non-renewable one logs `vault_static_token_not_renewable` and publishes its remaining TTL. The probe never fails the login: a token whose policy omits `lookup-self` (Vault's `default` policy grants it), or a Vault that is unreachable at that moment, logs `vault_static_token_lookup_failed` and falls back to the previous no-lease behaviour, so no deployment that works today stops working.
|
||||||
|
- **SSE-C over a plaintext transport is reported**: AWS S3 and MinIO refuse an SSE-C request that did not arrive over TLS, because the customer key travels in a request header. RustFS accepted them on any transport and still does by default — flipping to a rejection inside a release window would break plaintext staging and test deployments. Each such request now increments `rustfs_ssec_plaintext_requests_total` and logs one `ssec_request_without_tls` warning per process, and `RUSTFS_SSE_C_REQUIRE_TLS=true` opts into the AWS `400` now. The default is expected to flip in a later release; confirm the counter reads zero first. The verdict is per connection: a TLS listener satisfies it, and so does an `https` protocol forwarded by a proxy the trusted-proxy configuration accepts.
|
||||||
|
- **Local KMS backend on a distributed deployment says what actually breaks**: the backend keeps key material and its Argon2id salt on each node's own disk, so two nodes derive different keys from the same `master_key` and an object encrypted on one node cannot be decrypted on another — intermittent 500s behind a load balancer. Configuring it while the deployment is distributed now logs `kms_node_local_backend_in_distributed_deployment` and appends that consequence to the `kms/configure` response, instead of only the generic "development only" positioning warning. It remains a warning, not a gate.
|
||||||
|
- **SSE-KMS is refused when no KMS is running (breaking)**: a write requesting `x-amz-server-side-encryption: aws:kms` on a node with no KMS service no longer succeeds. Earlier releases wrapped the data key with the node-local `RUSTFS_SSE_S3_MASTER_KEY` while still writing `aws:kms` and the requested key id into the object metadata — metadata that claimed a KMS protection the object never had, under a key that was never consulted. Such a request now returns `400 InvalidRequest` when KMS was never configured and `503` when a configured service is not running; the refusal is evaluated after the per-key authorization gate, so an unauthorized caller still receives `403 AccessDenied`. **Upgrade note:** a deployment that relied on this write succeeding will start receiving 4xx/503. Either configure a KMS, or request `AES256` and keep the documented SSE-S3 local-master-key fallback, which is unchanged. Objects already written this way remain readable.
|
||||||
|
- **Legacy ciphertext nonce layouts are now locked per segment**: while decrypting a v1 segment, the reader locks onto whichever of the three historical nonce layouts decoded the segment's first non-zero-index frame and rejects any later frame that needs a different one. Because a frame encrypted at block index zero authenticates under the pre-`1.0.0-alpha.91` reused-part-nonce layout at any position, an attacker able to rewrite the underlying shards could previously replay it and have the forged plaintext returned with `200`. New `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK` (default `true`) drops that third layout entirely when set to `false`, which closes the residual case of a stream built purely from repeats of frame zero. Turn it off only after migrating pre-alpha.91 encrypted objects (rewrite in place with CopyObject); see [KMS backend security properties](docs/operations/kms-backend-security.md) for what the v1 frame layout does and does not authenticate.
|
||||||
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
||||||
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
|
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
|
||||||
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
||||||
|
|||||||
Generated
+124
-145
@@ -701,9 +701,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.44"
|
version = "0.4.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
|
checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -1031,9 +1031,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-sdk-s3"
|
name = "aws-sdk-s3"
|
||||||
version = "1.145.0"
|
version = "1.146.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0e6320417a37c8a62f78b443d0b4cf628b57cd340a09b0eb56173d47cc94e93"
|
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"aws-credential-types",
|
"aws-credential-types",
|
||||||
@@ -1735,18 +1735,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bon"
|
name = "bon"
|
||||||
version = "3.10.0"
|
version = "3.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6"
|
checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bon-macros",
|
"bon-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bon-macros"
|
name = "bon-macros"
|
||||||
version = "3.10.0"
|
version = "3.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5"
|
checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.24.1",
|
"darling 0.24.1",
|
||||||
"ident_case",
|
"ident_case",
|
||||||
@@ -2220,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2238,9 +2238,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.39"
|
version = "0.4.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
|
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"brotli 8.0.4",
|
"brotli 8.0.4",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
@@ -2249,8 +2249,8 @@ dependencies = [
|
|||||||
"liblzma",
|
"liblzma",
|
||||||
"lz4",
|
"lz4",
|
||||||
"memchr",
|
"memchr",
|
||||||
"zstd 0.13.3",
|
"zstd 0.14.0",
|
||||||
"zstd-safe 7.3.0",
|
"zstd-safe 8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2408,6 +2408,12 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_detect"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpp_demangle"
|
name = "cpp_demangle"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3927,16 +3933,6 @@ dependencies = [
|
|||||||
"dirs-sys",
|
"dirs-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs-next"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"dirs-sys-next",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs-sys"
|
name = "dirs-sys"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3945,21 +3941,10 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs-sys-next"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"redox_users 0.4.6",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch2"
|
name = "dispatch2"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -4208,11 +4193,17 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"core_detect",
|
||||||
|
"multiversion",
|
||||||
|
"multiversion_no_op",
|
||||||
|
"rustversion",
|
||||||
|
"scopeguard",
|
||||||
|
"simdutf8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5071,9 +5062,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.12.1"
|
version = "0.12.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
|
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
@@ -5287,9 +5278,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath"
|
name = "hotpath"
|
||||||
version = "0.25.0"
|
version = "0.25.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ec7782e005cabd5eaf350febde384cd799faa3a0e624587aa8759c240e0b592"
|
checksum = "366c5db5a8e4643bdde121a1d6f35cfa035e7850b8b8ea94c6960a139d44e93a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -5298,6 +5289,7 @@ dependencies = [
|
|||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hdrhistogram",
|
"hdrhistogram",
|
||||||
"hotpath-macros",
|
"hotpath-macros",
|
||||||
@@ -5306,9 +5298,8 @@ dependencies = [
|
|||||||
"object 0.36.7",
|
"object 0.36.7",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"prettytable-rs",
|
|
||||||
"quanta",
|
"quanta",
|
||||||
"regex",
|
"regex-lite",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
"rustc-demangle",
|
"rustc-demangle",
|
||||||
@@ -5410,9 +5401,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -5711,9 +5702,9 @@ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-uring"
|
name = "io-uring"
|
||||||
version = "0.7.14"
|
version = "0.7.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d64d8ca234d152948ceaede1f419b6a83983a5ecccaac05fb337a809c96d3aa6"
|
checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -5751,17 +5742,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is-terminal"
|
|
||||||
version = "0.4.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
|
||||||
dependencies = [
|
|
||||||
"hermit-abi",
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_debug"
|
name = "is_debug"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -6021,9 +6001,9 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lapin"
|
name = "lapin"
|
||||||
version = "4.10.0"
|
version = "4.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fd20e01fd92597ca352ca7ceed3c589851ebad279dfcada48aa4d24fd3a7caa"
|
checksum = "24b99d8cdfe3f6223f9f75e35e7510a8662767bee27c1cf870d503fc3bb2428a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"amq-protocol",
|
"amq-protocol",
|
||||||
"async-rs",
|
"async-rs",
|
||||||
@@ -6724,6 +6704,33 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
|
||||||
|
dependencies = [
|
||||||
|
"multiversion-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion-macros"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion_no_op"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "murmur3"
|
name = "murmur3"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -7709,9 +7716,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pe-unwind-info"
|
name = "pe-unwind-info"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97f6fccfd2d9d2df765ca23ff85fe5cc437fb0e6d3e164e4d3cbe09d14780c93"
|
checksum = "e33c6dbf1a8fb7f71742cd70f5e9f0986e60b2d19dc0b28d9ca0d1323259274a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
@@ -8187,19 +8194,6 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prettytable-rs"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
|
|
||||||
dependencies = [
|
|
||||||
"encode_unicode",
|
|
||||||
"is-terminal",
|
|
||||||
"lazy_static",
|
|
||||||
"term",
|
|
||||||
"unicode-width 0.1.14",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "primefield"
|
name = "primefield"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -8519,24 +8513,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-core"
|
name = "qrcode-core"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "79de8ed9b3c65c866e0ca79ba3aa8b05264e5183b0f894f1ea43b3a53146d657"
|
checksum = "5a5c0d85fe467f79319e9f0d0ffe43f8c32defae67263f78d57e33d8b4a638bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-decode"
|
name = "qrcode-decode"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41c307d82a6b418dc99004a68cdd621c0606874faae8645036eef9168ec5aef4"
|
checksum = "04cf729caefa8fd0919f8d111a4e6531942ac48742e7649ed4c9c159b41a6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-eps"
|
name = "qrcode-eps"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1cf7d12b9e539644309c628db9436be7024e0fbe16f440109ce92d7c9787688"
|
checksum = "d289a6212464685d1e598c3692e02189a096b2f4abe1acdb1f4fba6a932809d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8544,9 +8538,19 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-html"
|
name = "qrcode-html"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7fb7d790f2b34ce1edbafdf7a6802e73694aa94798be275c63acb780a39c1e6c"
|
checksum = "5a14b98d4aa6278f7e7218ad901837a85cef040a262127d0c4b6f8d3bd51d46e"
|
||||||
|
dependencies = [
|
||||||
|
"qrcode-core",
|
||||||
|
"qrcode-render",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode-image"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7923bee1dc13ef96b9778e9299d803ae9f5de38971cb8e55c141022cc296618"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8554,15 +8558,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-parse"
|
name = "qrcode-parse"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84f6484a2329e87b5a4b21c983d68bee32a442087287c0679efe782689ee469a"
|
checksum = "0726e7e2acd6335b63936ba1d784361765313f53274942efb0636407e882fdc9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-pdf"
|
name = "qrcode-pdf"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "deda8c86193091e31856024288e986869559ace2a71b7b477f59b4caea4dabf4"
|
checksum = "41ed9a51cab153e441963f8ec64ffc980d7a40bfd7fa8f6e0edd290424cda206"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8570,9 +8574,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-pic"
|
name = "qrcode-pic"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03bda277eb6e11b06e0ebe0cb1b519d71467a3f1d7f6408336af9a979edb1091"
|
checksum = "038be675ee01aeae82f20eea4b043054f8232e14d3ccc7836803510ad3ff6524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8580,23 +8584,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-render"
|
name = "qrcode-render"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a40771febec53bdf7de3b3de6549950a7c9f54eef58477d99f4de2fd9f114f9"
|
checksum = "7af1ddfd0f2496c28276913e26289e8fb245cab16ece7efcf9df46e2027dd3c8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-rs"
|
name = "qrcode-rs"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a62237103e62d01195453a4b34d56a35f59cf8c8ca3fa8ddcfca84336d5c4a2a"
|
checksum = "246e31e4a82d59d4714c589e4912f27bc67f5fa205cc4e3bdf916ccc640af010"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-decode",
|
"qrcode-decode",
|
||||||
"qrcode-eps",
|
"qrcode-eps",
|
||||||
"qrcode-html",
|
"qrcode-html",
|
||||||
|
"qrcode-image",
|
||||||
"qrcode-parse",
|
"qrcode-parse",
|
||||||
"qrcode-pdf",
|
"qrcode-pdf",
|
||||||
"qrcode-pic",
|
"qrcode-pic",
|
||||||
@@ -8606,9 +8611,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-svg"
|
name = "qrcode-svg"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "513f1dd2a0e11d1bcc4dd3268887cdad059ed8ab5269d45ce57aada1256c5405"
|
checksum = "19f641cac9a021d31948437c740baec47243c03586c3230c839b76df9e4df084"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8981,17 +8986,6 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_users"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"libredox",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -9084,11 +9078,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -9310,9 +9304,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "russh"
|
name = "russh"
|
||||||
version = "0.63.2"
|
version = "0.63.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e134e2480f4e86f83e4aa45b4c0a9723f84beaffa694c54bdf056e74efdd7dd"
|
checksum = "036204edbd199552a5b3832f63c60dcdf395dc44c7f06b4af1c0e8139cc11bce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -9393,9 +9387,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "russh-sftp"
|
name = "russh-sftp"
|
||||||
version = "2.4.0"
|
version = "3.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b"
|
checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -9928,6 +9922,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"base64-simd",
|
"base64-simd",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc-fast",
|
"crc-fast",
|
||||||
"futures",
|
"futures",
|
||||||
"hotpath",
|
"hotpath",
|
||||||
@@ -10578,6 +10573,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"temp-env",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
@@ -11084,9 +11080,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -11602,11 +11598,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.22.0"
|
version = "3.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -11623,14 +11619,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.22.0"
|
version = "3.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.23.0",
|
"darling 0.24.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12194,9 +12190,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "suppaftp"
|
name = "suppaftp"
|
||||||
version = "11.0.0"
|
version = "12.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46c5095831abc0d7944a2d50d6ec6abcd75b9d165d9377deb3e45798cae2343a"
|
checksum = "9c6978f1b04fc86f122f767b88a9d1d89e80cfe3dc97d670598507d713c4ce48"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -12415,17 +12411,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "term"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
|
|
||||||
dependencies = [
|
|
||||||
"dirs-next",
|
|
||||||
"rustversion",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termcolor"
|
name = "termcolor"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
@@ -13192,12 +13177,6 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-width"
|
|
||||||
version = "0.1.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -13998,18 +13977,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+10
-10
@@ -145,7 +145,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
|
|||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
async_zip = { default-features = false, version = "0.0.19" }
|
async_zip = { default-features = false, version = "0.0.19" }
|
||||||
mysql_async = { default-features = false, version = "0.37.1" }
|
mysql_async = { default-features = false, version = "0.37.1" }
|
||||||
async-compression = { version = "0.4.44" }
|
async-compression = { version = "0.4.46" }
|
||||||
async-recursion = "1.1.1"
|
async-recursion = "1.1.1"
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
async-nats = { version = "0.50.0", default-features = false }
|
async-nats = { version = "0.50.0", default-features = false }
|
||||||
@@ -156,7 +156,7 @@ futures-lite = "2.6.1"
|
|||||||
futures-util = "0.3.34"
|
futures-util = "0.3.34"
|
||||||
pollster = "1.0.1"
|
pollster = "1.0.1"
|
||||||
pulsar = { default-features = false, version = "6.9.0" }
|
pulsar = { default-features = false, version = "6.9.0" }
|
||||||
lapin = { default-features = false, version = "4.10.0" }
|
lapin = { default-features = false, version = "4.11.0" }
|
||||||
hyper = { version = "1.11.1" }
|
hyper = { version = "1.11.1" }
|
||||||
hyper-rustls = { default-features = false, version = "0.27.9" }
|
hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||||
hyper-util = { version = "0.1.20" }
|
hyper-util = { version = "0.1.20" }
|
||||||
@@ -164,7 +164,7 @@ http = "1.5.0"
|
|||||||
http-body = "1.1.0"
|
http-body = "1.1.0"
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
minlz = "1.2.3"
|
minlz = "1.2.3"
|
||||||
reqwest = "0.13.4"
|
reqwest = "0.13.5"
|
||||||
rustfs-kafka-async = { version = "1.3.1" }
|
rustfs-kafka-async = { version = "1.3.1" }
|
||||||
socket2 = { version = "0.6.5" }
|
socket2 = { version = "0.6.5" }
|
||||||
tokio = { version = "1.53.1" }
|
tokio = { version = "1.53.1" }
|
||||||
@@ -211,7 +211,7 @@ openidconnect = { default-features = false, version = "4.0" }
|
|||||||
pbkdf2 = "0.13.0"
|
pbkdf2 = "0.13.0"
|
||||||
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
||||||
rsa = { version = "=0.10.0-rc.18" }
|
rsa = { version = "=0.10.0-rc.18" }
|
||||||
rustls = { default-features = false, version = "0.23.43" }
|
rustls = { default-features = false, version = "0.23.44" }
|
||||||
rustls-native-certs = "0.8"
|
rustls-native-certs = "0.8"
|
||||||
rustls-pki-types = "1.15.1"
|
rustls-pki-types = "1.15.1"
|
||||||
x509-parser = "0.18.1"
|
x509-parser = "0.18.1"
|
||||||
@@ -245,7 +245,7 @@ atomic_enum = "0.3.0"
|
|||||||
aws-config = { version = "1.12.0" }
|
aws-config = { version = "1.12.0" }
|
||||||
aws-credential-types = { version = "1.3.0" }
|
aws-credential-types = { version = "1.3.0" }
|
||||||
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.145.0" }
|
aws-sdk-s3 = { default-features = false, version = "1.146.0" }
|
||||||
aws-sdk-sts = { default-features = false, version = "1.114.0" }
|
aws-sdk-sts = { default-features = false, version = "1.114.0" }
|
||||||
aws-smithy-async = { version = "1.3.0" }
|
aws-smithy-async = { version = "1.3.0" }
|
||||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||||
@@ -297,7 +297,7 @@ percent-encoding = "2.3.2"
|
|||||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||||
# CLI needs its own QR encoder. No default features: the image/render backends
|
# CLI needs its own QR encoder. No default features: the image/render backends
|
||||||
# pull in an image stack this only needs SVG and text output from.
|
# pull in an image stack this only needs SVG and text output from.
|
||||||
qrcode-rs = { version = "2.0.0", default-features = false, features = ["std", "svg"] }
|
qrcode-rs = { version = "2.1.0", default-features = false, features = ["std", "svg"] }
|
||||||
pin-project-lite = "0.2.17"
|
pin-project-lite = "0.2.17"
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
rand = { version = "0.10.2" }
|
rand = { version = "0.10.2" }
|
||||||
@@ -362,10 +362,10 @@ pyroscope = { version = "2.1.1" }
|
|||||||
# FTP and SFTP
|
# FTP and SFTP
|
||||||
libunftp = { version = "0.23.0" }
|
libunftp = { version = "0.23.0" }
|
||||||
unftp-core = "0.1.0"
|
unftp-core = "0.1.0"
|
||||||
suppaftp = { version = "11.0.0" }
|
suppaftp = { version = "12.0.0" }
|
||||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||||
russh = { version = "0.63.2" }
|
russh = { version = "0.63.3" }
|
||||||
russh-sftp = "2.4.0"
|
russh-sftp = "3.0.0"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
dav-server = "0.11.0"
|
dav-server = "0.11.0"
|
||||||
@@ -373,7 +373,7 @@ dav-server = "0.11.0"
|
|||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
rustfs-mimalloc = { version = "0.5.3" }
|
rustfs-mimalloc = { version = "0.5.3" }
|
||||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||||
hotpath = { version = "=0.25.0", default-features = false }
|
hotpath = { version = "0.25.1", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,10 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build multi-architecture images locally
|
# Build multi-architecture images locally
|
||||||
./docker-buildx.sh --build-arg RELEASE=latest
|
./docker-buildx.sh
|
||||||
|
|
||||||
|
# Build a single-platform image locally
|
||||||
|
./docker-buildx.sh -p linux/amd64
|
||||||
|
|
||||||
# Build and push to registry
|
# Build and push to registry
|
||||||
./docker-buildx.sh --push
|
./docker-buildx.sh --push
|
||||||
|
|||||||
+29
-9
@@ -46,14 +46,31 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
|||||||
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
||||||
- **简单易用**:设计简洁,易于部署和管理。
|
- **简单易用**:设计简洁,易于部署和管理。
|
||||||
|
|
||||||
| 功能 | 状态 | 功能 | 状态 |
|
状态说明:✅ 可用 —— 已发布并有 CI 门禁覆盖;🧪 预览 —— 已发布但需显式开关,或兼容性承诺有边界。
|
||||||
| :----------------- | :------ | :---------------------- | :-------- |
|
|
||||||
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
| 功能 | 状态 | 功能 | 状态 |
|
||||||
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
| :-------------------------- | :------ | :----------------------- | :------ |
|
||||||
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
| **S3 核心功能** | ✅ 可用 | **分布式模式** | ✅ 可用 |
|
||||||
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
|
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
||||||
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
|
| **版本控制** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
||||||
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
|
| **对象锁定 (WORM)** | ✅ 可用 | **修复与扫描器** | ✅ 可用 |
|
||||||
|
| **服务端加密 (SSE)** | ✅ 可用 | **存储池扩容 / 下线** | ✅ 可用 |
|
||||||
|
| **RustFS KMS** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
||||||
|
| **生命周期管理 (ILM)** | ✅ 可用 | **站点复制** | ✅ 可用 |
|
||||||
|
| **ILM 分层 (远端 S3)** | ✅ 可用 | **存储桶配额** | ✅ 可用 |
|
||||||
|
| **S3 Select** | ✅ 可用 | **事件通知** | ✅ 可用 |
|
||||||
|
| **S3 Tables (Iceberg REST)**| 🧪 预览 | **审计日志** | ✅ 可用 |
|
||||||
|
| **IAM / 策略** | ✅ 可用 | **日志与可观测性** | ✅ 可用 |
|
||||||
|
| **OIDC / SSO** | ✅ 可用 | **Web 控制台** | ✅ 可用 |
|
||||||
|
| **Keystone 认证** | ✅ 可用 | **K8s Helm Chart** | ✅ 可用 |
|
||||||
|
| **Swift API** | ✅ 可用 | **FTPS / WebDAV** | ✅ 可用 |
|
||||||
|
| **多租户** | ✅ 可用 | **SFTP** | ✅ 可用 |
|
||||||
|
| **MinIO 磁盘格式兼容** | 🧪 预览 | | |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- **服务端加密**:支持 SSE-C、SSE-S3 与 SSE-KMS。SSE-KMS 必须先配置 KMS 服务;未配置 KMS 时请求 `aws:kms` 会被拒绝,不会降级到本地主密钥。
|
||||||
|
- **RustFS KMS**:生产环境支持 Vault(KV2 / Transit)与 AWS KMS 后端;`Local` 与 `Static` 后端仅供开发与测试使用,详见 [KMS 后端安全属性](docs/operations/kms-backend-security.md)。
|
||||||
|
|
||||||
## RustFS vs MinIO 性能对比
|
## RustFS vs MinIO 性能对比
|
||||||
|
|
||||||
@@ -150,7 +167,10 @@ docker compose -f docker-compose-simple.yml up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在本地构建多架构镜像
|
# 在本地构建多架构镜像
|
||||||
./docker-buildx.sh --build-arg RELEASE=latest
|
./docker-buildx.sh
|
||||||
|
|
||||||
|
# 在本地构建单平台镜像
|
||||||
|
./docker-buildx.sh -p linux/amd64
|
||||||
|
|
||||||
# 构建并推送到仓库
|
# 构建并推送到仓库
|
||||||
./docker-buildx.sh --push
|
./docker-buildx.sh --push
|
||||||
|
|||||||
@@ -145,6 +145,38 @@ pub fn consume_verified_mrf_repair_events(anchors: &mut Vec<MrfDurableRepairAnch
|
|||||||
before.saturating_sub(anchors.len())
|
before.saturating_sub(anchors.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consume recorded verified repairs for one bucket without draining
|
||||||
|
/// unrelated or still-unmatched proofs. If the caller crashes before
|
||||||
|
/// persisting the retained anchor set, the proof may be replayed by repair
|
||||||
|
/// instead of silently deleting the old responsibility.
|
||||||
|
pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &mut Vec<MrfDurableRepairAnchor>) -> usize {
|
||||||
|
let Some(registry) = MRF_VERIFIED_REPAIR_EVENTS.get() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let Ok(mut events) = registry.lock() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let before = anchors.len();
|
||||||
|
let mut retained = std::collections::VecDeque::with_capacity(events.len());
|
||||||
|
while let Some(event) = events.pop_front() {
|
||||||
|
if event.bucket.as_ref() != bucket {
|
||||||
|
retained.push_back(event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut matched = false;
|
||||||
|
anchors.retain(|anchor| {
|
||||||
|
let proven = anchor.is_proven_by(&event);
|
||||||
|
matched |= proven;
|
||||||
|
!proven
|
||||||
|
});
|
||||||
|
if !matched {
|
||||||
|
retained.push_back(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*events = retained;
|
||||||
|
before.saturating_sub(anchors.len())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct MrfScope {
|
pub struct MrfScope {
|
||||||
pub pool_index: u32,
|
pub pool_index: u32,
|
||||||
@@ -821,6 +853,71 @@ mod tests {
|
|||||||
assert!(retained.is_empty());
|
assert!(retained.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recorded_verified_repair_consumer_retains_unmatched_proofs() {
|
||||||
|
let bucket = Arc::<str>::from(format!("recorded-proof-{}", Uuid::new_v4()));
|
||||||
|
let other_bucket = Arc::<str>::from(format!("recorded-proof-other-{}", Uuid::new_v4()));
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let lease = MrfIngressLease::new(21);
|
||||||
|
let retained_anchor = MrfDurableRepairAnchor {
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
object: Arc::from("retained"),
|
||||||
|
version_id: Some([7; 16]),
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 1,
|
||||||
|
set_index: 2,
|
||||||
|
}),
|
||||||
|
lease,
|
||||||
|
bucket_incarnation_id: incarnation,
|
||||||
|
};
|
||||||
|
let waiting_anchor = MrfDurableRepairAnchor {
|
||||||
|
object: Arc::from("waiting"),
|
||||||
|
lease: MrfIngressLease::new(22),
|
||||||
|
..retained_anchor.clone()
|
||||||
|
};
|
||||||
|
let matched_event = MrfVerifiedRepairEvent {
|
||||||
|
kind: retained_anchor.kind,
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
object: retained_anchor.object.clone(),
|
||||||
|
version_id: retained_anchor.version_id,
|
||||||
|
scope: retained_anchor.scope,
|
||||||
|
lease: Some(retained_anchor.lease),
|
||||||
|
bucket_incarnation_id: retained_anchor.bucket_incarnation_id,
|
||||||
|
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||||
|
};
|
||||||
|
let same_bucket_unmatched = MrfVerifiedRepairEvent {
|
||||||
|
object: Arc::from("future"),
|
||||||
|
lease: Some(MrfIngressLease::new(23)),
|
||||||
|
..matched_event.clone()
|
||||||
|
};
|
||||||
|
let other_bucket_event = MrfVerifiedRepairEvent {
|
||||||
|
bucket: other_bucket.clone(),
|
||||||
|
..matched_event.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
note_mrf_verified_repair(matched_event);
|
||||||
|
note_mrf_verified_repair(same_bucket_unmatched.clone());
|
||||||
|
note_mrf_verified_repair(other_bucket_event.clone());
|
||||||
|
|
||||||
|
let mut anchors = vec![retained_anchor, waiting_anchor.clone()];
|
||||||
|
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&bucket, &mut anchors), 1);
|
||||||
|
assert_eq!(anchors, vec![waiting_anchor]);
|
||||||
|
|
||||||
|
let remaining_bucket_events = take_mrf_verified_repair_events_for(&bucket);
|
||||||
|
assert_eq!(
|
||||||
|
remaining_bucket_events,
|
||||||
|
vec![same_bucket_unmatched],
|
||||||
|
"same-bucket proofs without a retained anchor must remain available"
|
||||||
|
);
|
||||||
|
let remaining_other_events = take_mrf_verified_repair_events_for(&other_bucket);
|
||||||
|
assert_eq!(
|
||||||
|
remaining_other_events,
|
||||||
|
vec![other_bucket_event],
|
||||||
|
"proofs for other buckets must not be drained by this consumer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn try_send_delivers_and_respects_capacity() {
|
async fn try_send_delivers_and_respects_capacity() {
|
||||||
let mut receiver = init_mrf_channel().expect("first initialization should succeed");
|
let mut receiver = init_mrf_channel().expect("first initialization should succeed");
|
||||||
|
|||||||
@@ -609,6 +609,36 @@ pub const ENV_OBJECT_LOCK_RPC_TIMEOUT_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_TIMEOUT
|
|||||||
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
||||||
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
||||||
|
|
||||||
|
/// Environment variable for the minimum interval between evictions of the
|
||||||
|
/// cached lock RPC channel to one peer, in milliseconds.
|
||||||
|
///
|
||||||
|
/// A lock RPC that fails on transport, or that times out while the peer has
|
||||||
|
/// not completed any lock RPC for two deadlines, evicts the shared HTTP/2
|
||||||
|
/// channel so the next request re-dials. Evictions are rate limited per peer
|
||||||
|
/// so one slow lock endpoint cannot drive a reset/GOAWAY/reconnect loop
|
||||||
|
/// (issue #7363). `0` disables the cooldown.
|
||||||
|
///
|
||||||
|
/// Default: 5000 milliseconds.
|
||||||
|
pub const ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS";
|
||||||
|
|
||||||
|
/// Default minimum interval between lock RPC channel evictions per peer: 5000 milliseconds.
|
||||||
|
pub const DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: u64 = 5000;
|
||||||
|
|
||||||
|
/// Environment variable for how many timed-out lock RPCs per peer may keep
|
||||||
|
/// running in the background instead of being cancelled.
|
||||||
|
///
|
||||||
|
/// Cancelling a timed-out stream sends `RST_STREAM`; enough of them make the
|
||||||
|
/// peer answer `GOAWAY too_many_resets` and drop every stream on the
|
||||||
|
/// connection. A detached RPC ends on its own within the internode RPC
|
||||||
|
/// timeout, and a lock it acquires after its caller gave up is released
|
||||||
|
/// immediately. Beyond this budget timed-out RPCs are cancelled as before.
|
||||||
|
///
|
||||||
|
/// Default: 256.
|
||||||
|
pub const ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT";
|
||||||
|
|
||||||
|
/// Default per-peer budget of detached (timed-out but still running) lock RPCs: 256.
|
||||||
|
pub const DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT: usize = 256;
|
||||||
|
|
||||||
/// Environment variable to enable object namespace lock diagnostics.
|
/// Environment variable to enable object namespace lock diagnostics.
|
||||||
///
|
///
|
||||||
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
||||||
|
|||||||
@@ -593,6 +593,20 @@ pub struct DataUsageSnapshotIdentity {
|
|||||||
pub scanner_epoch: Option<u64>,
|
pub scanner_epoch: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DataUsageSegmentInvalidationProof {
|
||||||
|
#[serde(default)]
|
||||||
|
pub process_epoch: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub generation_start: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub generation_end: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub producer_identity_coverage_complete: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cold_zero_walk_oracle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct DataUsageSnapshotSetState {
|
pub struct DataUsageSnapshotSetState {
|
||||||
pub pool_index: u64,
|
pub pool_index: u64,
|
||||||
@@ -607,6 +621,8 @@ pub struct DataUsageSnapshotSetState {
|
|||||||
pub complete: bool,
|
pub complete: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tombstone: bool,
|
pub tombstone: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataUsageInfo {
|
impl DataUsageInfo {
|
||||||
@@ -3073,6 +3089,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([1; 32]),
|
scan_plan_digest: Some([1; 32]),
|
||||||
complete: false,
|
complete: false,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
}];
|
}];
|
||||||
assert!(observed_data_usage_is_newer(&partial, &authoritative));
|
assert!(observed_data_usage_is_newer(&partial, &authoritative));
|
||||||
}
|
}
|
||||||
@@ -3095,6 +3112,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([1; 32]),
|
scan_plan_digest: Some([1; 32]),
|
||||||
complete: true,
|
complete: true,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
},
|
},
|
||||||
DataUsageSnapshotSetState {
|
DataUsageSnapshotSetState {
|
||||||
pool_index: 1,
|
pool_index: 1,
|
||||||
@@ -3104,6 +3122,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([2; 32]),
|
scan_plan_digest: Some([2; 32]),
|
||||||
complete: false,
|
complete: false,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -3113,6 +3132,42 @@ mod tests {
|
|||||||
assert!(partial.is_valid_partial_snapshot());
|
assert!(partial.is_valid_partial_snapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_state_segment_invalidation_proof_is_additive() {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LegacySetState {
|
||||||
|
pool_index: u64,
|
||||||
|
set_index: u64,
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let proof = DataUsageSegmentInvalidationProof {
|
||||||
|
process_epoch: "scanner-process".to_string(),
|
||||||
|
generation_start: 3,
|
||||||
|
generation_end: 5,
|
||||||
|
producer_identity_coverage_complete: true,
|
||||||
|
cold_zero_walk_oracle: true,
|
||||||
|
};
|
||||||
|
let state = DataUsageSnapshotSetState {
|
||||||
|
pool_index: 1,
|
||||||
|
set_index: 2,
|
||||||
|
scanner_cycle: Some(9),
|
||||||
|
scanner_epoch: Some(4),
|
||||||
|
scan_plan_digest: Some([7; 32]),
|
||||||
|
complete: true,
|
||||||
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: Some(proof.clone()),
|
||||||
|
};
|
||||||
|
let encoded = rmp_serde::to_vec_named(&state).expect("set state should encode with additive proof");
|
||||||
|
let legacy: LegacySetState = rmp_serde::from_slice(&encoded).expect("legacy readers should ignore proof metadata");
|
||||||
|
assert_eq!(legacy.pool_index, 1);
|
||||||
|
assert_eq!(legacy.set_index, 2);
|
||||||
|
assert!(legacy.complete);
|
||||||
|
|
||||||
|
let decoded: DataUsageSnapshotSetState = rmp_serde::from_slice(&encoded).expect("new readers should restore proof");
|
||||||
|
assert_eq!(decoded.segment_invalidation_proof, Some(proof));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completeness_marker_requires_a_snapshot_timestamp() {
|
fn completeness_marker_requires_a_snapshot_timestamp() {
|
||||||
let untimestamped = DataUsageInfo {
|
let untimestamped = DataUsageInfo {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||||
|
|
||||||
|
The external-tool `storage_metric_ownership_test` validates the OTLP/Collector/Prometheus path, including a rolling upgrade and node failures. See the [storage metrics guide](../../docs/operations/storage-metrics.md) for its required binaries and focused command.
|
||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
|
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
|
||||||
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
|
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=no"]).is_none_or(|status| !status.is_empty());
|
||||||
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
||||||
let mut features = std::env::vars()
|
let mut features = std::env::vars()
|
||||||
.filter_map(|(key, _)| {
|
.filter_map(|(key, _)| {
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
|
|||||||
let rule = CorsRule::builder()
|
let rule = CorsRule::builder()
|
||||||
.allowed_methods("GET")
|
.allowed_methods("GET")
|
||||||
.allowed_origins("https://example.com")
|
.allowed_origins("https://example.com")
|
||||||
|
.allowed_headers("*")
|
||||||
.build()?;
|
.build()?;
|
||||||
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
|
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
|
||||||
|
|
||||||
@@ -288,6 +289,60 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
|
|||||||
assert_eq!(rules[0].allowed_methods(), ["GET"]);
|
assert_eq!(rules[0].allowed_methods(), ["GET"]);
|
||||||
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
|
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
|
||||||
|
|
||||||
|
let http = reqwest::Client::builder().no_proxy().build()?;
|
||||||
|
let url = format!("http://{}/{}", cluster.nodes[1].address, BUCKET_METADATA_RELOAD_BUCKET);
|
||||||
|
let without_headers = http
|
||||||
|
.request(reqwest::Method::OPTIONS, &url)
|
||||||
|
.header("Origin", "https://example.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(without_headers.status().is_success());
|
||||||
|
assert!(!without_headers.headers().contains_key("access-control-allow-headers"));
|
||||||
|
assert!(
|
||||||
|
without_headers
|
||||||
|
.headers()
|
||||||
|
.get("vary")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
|
||||||
|
"a cached header-free preflight must not suppress a later requested header grant"
|
||||||
|
);
|
||||||
|
let preflight = http
|
||||||
|
.request(reqwest::Method::OPTIONS, &url)
|
||||||
|
.header("Origin", "https://example.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.header("Access-Control-Request-Headers", "X-Another-Header, x-could-be-anything")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(preflight.status().is_success(), "peer preflight should succeed: {preflight:?}");
|
||||||
|
assert_eq!(
|
||||||
|
preflight
|
||||||
|
.headers()
|
||||||
|
.get("access-control-allow-headers")
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("x-another-header,x-could-be-anything"),
|
||||||
|
"a wildcard rule must return only the headers requested by this preflight"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
preflight
|
||||||
|
.headers()
|
||||||
|
.get("vary")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
|
||||||
|
"preflight caches must distinguish the requested header list"
|
||||||
|
);
|
||||||
|
let denied = http
|
||||||
|
.request(reqwest::Method::OPTIONS, &url)
|
||||||
|
.header("Origin", "https://disallowed.example.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.header("Access-Control-Request-Headers", "x-another-header")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
!denied.headers().contains_key("access-control-allow-headers"),
|
||||||
|
"a rejected origin must not receive the requested header grant"
|
||||||
|
);
|
||||||
|
|
||||||
writer
|
writer
|
||||||
.delete_bucket_cors()
|
.delete_bucket_cors()
|
||||||
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
||||||
|
|||||||
@@ -188,6 +188,15 @@ fn write_next_test_port(port: u16) -> Result<(), Box<dyn std::error::Error + Sen
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn no_available_test_port_error(config: TestPortAllocatorConfig, attempts: u16, last_error: Option<&std::io::Error>) -> String {
|
||||||
|
let max_inclusive = config.max_exclusive() - 1;
|
||||||
|
let detail = last_error.map(|err| format!("; last bind error: {err}")).unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"no available E2E test port found in {}..={} after {} attempts{}",
|
||||||
|
config.min, max_inclusive, attempts, detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn capture_command_logs(
|
pub(crate) fn capture_command_logs(
|
||||||
command: &mut Command,
|
command: &mut Command,
|
||||||
log_path: Option<&str>,
|
log_path: Option<&str>,
|
||||||
@@ -694,19 +703,23 @@ impl RustFSTestEnvironment {
|
|||||||
let _guard = PortAllocatorGuard::acquire().await?;
|
let _guard = PortAllocatorGuard::acquire().await?;
|
||||||
let config = test_port_allocator_config()?;
|
let config = test_port_allocator_config()?;
|
||||||
let mut next_port = read_next_test_port(config);
|
let mut next_port = read_next_test_port(config);
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
for _ in 0..config.range {
|
for _ in 0..config.range {
|
||||||
let port = next_port;
|
let port = next_port;
|
||||||
next_port = advance_test_port(next_port, config);
|
next_port = advance_test_port(next_port, config);
|
||||||
write_next_test_port(next_port)?;
|
write_next_test_port(next_port)?;
|
||||||
|
|
||||||
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
|
match TcpListener::bind(("127.0.0.1", port)) {
|
||||||
drop(listener);
|
Ok(listener) => {
|
||||||
return Ok(port);
|
drop(listener);
|
||||||
|
return Ok(port);
|
||||||
|
}
|
||||||
|
Err(err) => last_error = Some(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("no available E2E test port found".into())
|
Err(no_available_test_port_error(config, config.range, last_error.as_ref()).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill any existing RustFS processes
|
/// Kill any existing RustFS processes
|
||||||
@@ -2201,6 +2214,19 @@ mod tests {
|
|||||||
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
|
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e_port_allocator_reports_attempt_window_and_last_bind_error() {
|
||||||
|
let config = TestPortAllocatorConfig { min: 41000, range: 3 };
|
||||||
|
let error = std::io::Error::from(ErrorKind::PermissionDenied);
|
||||||
|
|
||||||
|
let message = no_available_test_port_error(config, config.range, Some(&error));
|
||||||
|
|
||||||
|
assert!(message.contains("41000..=41002"));
|
||||||
|
assert!(message.contains("after 3 attempts"));
|
||||||
|
assert!(message.contains("last bind error"));
|
||||||
|
assert!(message.contains("permission denied"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
||||||
let workspace = Path::new("workspace");
|
let workspace = Path::new("workspace");
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ const POOL_META_V3_ENV: [(&str, &str); 2] = [
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub(crate) enum DistLayout {
|
pub(crate) enum DistLayout {
|
||||||
|
/// 3 nodes × 4 drives, one erasure pool. With `EC:4` this is the
|
||||||
|
/// release-evidence EC8+4 geometry.
|
||||||
|
ThreeByFourEc84,
|
||||||
/// 4 nodes × 4 drives, one erasure pool spanning every endpoint.
|
/// 4 nodes × 4 drives, one erasure pool spanning every endpoint.
|
||||||
FourByFour,
|
FourByFour,
|
||||||
/// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout).
|
/// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout).
|
||||||
@@ -94,6 +97,7 @@ impl DistCluster {
|
|||||||
|
|
||||||
pub async fn new_stopped_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
pub async fn new_stopped_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
||||||
let topology = match layout {
|
let topology = match layout {
|
||||||
|
DistLayout::ThreeByFourEc84 => ClusterTopology::single_pool_multidrive(3, DRIVES_PER_NODE),
|
||||||
DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE),
|
DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE),
|
||||||
DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT),
|
DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT),
|
||||||
DistLayout::SingleNodeFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0]]),
|
DistLayout::SingleNodeFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0]]),
|
||||||
@@ -101,7 +105,7 @@ impl DistCluster {
|
|||||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?;
|
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?;
|
||||||
let pool_storage_roots = match layout {
|
let pool_storage_roots = match layout {
|
||||||
DistLayout::SingleNodeFourDrive => Some(configured_pool_storage_roots()?),
|
DistLayout::SingleNodeFourDrive => Some(configured_pool_storage_roots()?),
|
||||||
DistLayout::FourByFour | DistLayout::FourNodeFourDisk => None,
|
DistLayout::ThreeByFourEc84 | DistLayout::FourByFour | DistLayout::FourNodeFourDisk => None,
|
||||||
};
|
};
|
||||||
let mut owned_pool_dirs = Vec::new();
|
let mut owned_pool_dirs = Vec::new();
|
||||||
if let Some(roots) = pool_storage_roots.as_deref() {
|
if let Some(roots) = pool_storage_roots.as_deref() {
|
||||||
@@ -958,27 +962,6 @@ pub(crate) async fn wait_for_rebalance_active(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn wait_for_rebalance_running_with_progress(
|
|
||||||
cluster: &RustFSTestClusterEnvironment,
|
|
||||||
expected_id: &str,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> TestResult {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
let status = rebalance_status_json(cluster).await?;
|
|
||||||
if rebalance_running_with_progress(&status, expected_id)? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
return Err(format!(
|
|
||||||
"rebalance did not become active with non-zero progress within {timeout:?}; last status: {status}"
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn wait_for_rebalance_complete(
|
pub(crate) async fn wait_for_rebalance_complete(
|
||||||
cluster: &RustFSTestClusterEnvironment,
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
expected_id: &str,
|
expected_id: &str,
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
// 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::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
|
||||||
|
wait_until,
|
||||||
|
};
|
||||||
|
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::{Instant, sleep};
|
||||||
|
|
||||||
|
const EC84_NODE_COUNT: usize = 3;
|
||||||
|
const EC84_DRIVES_PER_NODE: usize = 4;
|
||||||
|
const EC84_DATA_BLOCKS: usize = 8;
|
||||||
|
const EC84_PARITY_BLOCKS: usize = 4;
|
||||||
|
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
|
||||||
|
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
|
||||||
|
const EC84_HEAL_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(45);
|
||||||
|
const EC84_HEAL_CONTROL_RETRY_DELAY: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ExpectedShard {
|
||||||
|
key: String,
|
||||||
|
body: Vec<u8>,
|
||||||
|
baseline: VersionShardCensus,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScannerHealEvidenceContext {
|
||||||
|
directory: PathBuf,
|
||||||
|
run: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScannerHealEvidencePayload<'a> {
|
||||||
|
dist: &'a DistCluster,
|
||||||
|
bucket: &'a str,
|
||||||
|
expected: &'a [ExpectedShard],
|
||||||
|
outage_key: &'a str,
|
||||||
|
outage_body: &'a [u8],
|
||||||
|
replaced_drive: &'a Path,
|
||||||
|
pid_before: u32,
|
||||||
|
pid_after: u32,
|
||||||
|
node_listings: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_sha256(path: &Path) -> TestResult<String> {
|
||||||
|
let mut file = std::fs::File::open(path)?;
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
let mut buffer = [0_u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = file.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
digest.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compiled_test_identity() -> Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||||
|
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
||||||
|
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
||||||
|
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
||||||
|
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
||||||
|
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
||||||
|
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
|
||||||
|
let mut current = value;
|
||||||
|
for segment in path.split('.') {
|
||||||
|
current = current
|
||||||
|
.get(segment)
|
||||||
|
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
|
||||||
|
}
|
||||||
|
current
|
||||||
|
.as_str()
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
|
||||||
|
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let directory = PathBuf::from(directory);
|
||||||
|
let receipt = directory.join("run.json");
|
||||||
|
if receipt.metadata()?.len() > 1024 * 1024 {
|
||||||
|
return Err("oversized scanner/heal execution receipt".into());
|
||||||
|
}
|
||||||
|
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
||||||
|
let built = compiled_test_identity();
|
||||||
|
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
||||||
|
if built[key] != run["test_build"][key] {
|
||||||
|
return Err(format!("compiled test identity differs for {key}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
|
||||||
|
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
|
||||||
|
return Err("server binary must match the run receipt".into());
|
||||||
|
}
|
||||||
|
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
|
||||||
|
return Err("test executable must match the run receipt".into());
|
||||||
|
}
|
||||||
|
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
|
||||||
|
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||||
|
}
|
||||||
|
Ok(Some(ScannerHealEvidenceContext { directory, run }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
|
||||||
|
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
|
||||||
|
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
|
||||||
|
}
|
||||||
|
let erasure_index = census
|
||||||
|
.erasure_index
|
||||||
|
.ok_or_else(|| format!("object {key} did not record an erasure index: {census:?}"))?;
|
||||||
|
if !(1..=EC84_DATA_BLOCKS + EC84_PARITY_BLOCKS).contains(&erasure_index) {
|
||||||
|
return Err(format!("object {key} has out-of-range erasure index {erasure_index}: {census:?}").into());
|
||||||
|
}
|
||||||
|
if !census.is_complete() || census.expected_part_numbers.is_empty() {
|
||||||
|
return Err(format!("object {key} does not have complete physical shard evidence: {census:?}").into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payload: ScannerHealEvidencePayload<'_>) -> TestResult {
|
||||||
|
let verifier = payload.dist.client(0)?;
|
||||||
|
let mut objects = Vec::new();
|
||||||
|
for item in payload.expected {
|
||||||
|
let actual = get_object_bytes(&verifier, payload.bucket, &item.key).await?;
|
||||||
|
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, &item.key, None)?;
|
||||||
|
objects.push(serde_json::json!({
|
||||||
|
"key": item.key,
|
||||||
|
"version_id": null,
|
||||||
|
"expected_bytes": item.body.len(),
|
||||||
|
"actual_bytes": actual.len(),
|
||||||
|
"expected_sha256": sha256_hex(&item.body),
|
||||||
|
"actual_sha256": sha256_hex(&actual),
|
||||||
|
"expected_physical": item.baseline,
|
||||||
|
"physical": physical,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let actual = get_object_bytes(&verifier, payload.bucket, payload.outage_key).await?;
|
||||||
|
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, payload.outage_key, None)?;
|
||||||
|
objects.push(serde_json::json!({
|
||||||
|
"key": payload.outage_key,
|
||||||
|
"version_id": null,
|
||||||
|
"expected_bytes": payload.outage_body.len(),
|
||||||
|
"actual_bytes": actual.len(),
|
||||||
|
"expected_sha256": sha256_hex(payload.outage_body),
|
||||||
|
"actual_sha256": sha256_hex(&actual),
|
||||||
|
"expected_physical": null,
|
||||||
|
"physical": physical,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let evidence = serde_json::json!({
|
||||||
|
"schema": 1,
|
||||||
|
"case": EC84_TARGET_DRIVE_RESTART_CASE,
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"run_id": string_field(&context.run, "run_id")?,
|
||||||
|
"source_revision": string_field(&context.run, "source_revision")?,
|
||||||
|
"test_build": compiled_test_identity(),
|
||||||
|
"binary_sha256": string_field(&context.run, "binary.sha256")?,
|
||||||
|
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
|
||||||
|
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
|
||||||
|
"pid_before": payload.pid_before,
|
||||||
|
"pid_after": payload.pid_after,
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"objects": objects,
|
||||||
|
"node_listings": payload.node_listings,
|
||||||
|
});
|
||||||
|
let data = serde_json::to_vec(&evidence)?;
|
||||||
|
if data.len() > 1024 * 1024 {
|
||||||
|
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
||||||
|
}
|
||||||
|
let mut output = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
|
||||||
|
output.write_all(&data)?;
|
||||||
|
output.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
|
||||||
|
for key in keys {
|
||||||
|
let census = census_object_version_on_disk(drive, bucket, key, None)?;
|
||||||
|
if census.has_xl_meta {
|
||||||
|
return Err(format!("replacement drive unexpectedly retained {bucket}/{key}: {census:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_cluster_heal_coordination_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool {
|
||||||
|
let message = error.to_string();
|
||||||
|
message.contains("500 Internal Server Error") && message.contains("cluster heal coordination unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_ec84_root_heal_when_control_ready(
|
||||||
|
heal_url: &str,
|
||||||
|
heal_body: &str,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> TestResult {
|
||||||
|
let deadline = Instant::now() + EC84_HEAL_CONTROL_READY_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
match signed_admin_post(heal_url, Some(heal_body), access_key, secret_key).await {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(error) if is_cluster_heal_coordination_unavailable(error.as_ref()) && Instant::now() < deadline => {
|
||||||
|
sleep(EC84_HEAL_CONTROL_RETRY_DELAY).await;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> {
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for index in 0..4 {
|
||||||
|
let key = format!("ec84/prefix-{}/object-{index:04}.bin", index % 2);
|
||||||
|
let body = payload_for(&key, 10 * 1024 * 1024);
|
||||||
|
put_object(client, bucket, &key, body.clone()).await?;
|
||||||
|
expected.push(ExpectedShard {
|
||||||
|
key,
|
||||||
|
body,
|
||||||
|
baseline: VersionShardCensus {
|
||||||
|
version_id: None,
|
||||||
|
has_xl_meta: false,
|
||||||
|
data_dir: None,
|
||||||
|
erasure_index: None,
|
||||||
|
data_blocks: None,
|
||||||
|
parity_blocks: None,
|
||||||
|
expected_part_numbers: Default::default(),
|
||||||
|
present_part_fingerprints: Default::default(),
|
||||||
|
inline_data_fingerprint: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let evidence_context = scanner_heal_evidence_context()?;
|
||||||
|
let mut dist = DistCluster::start_with_env(
|
||||||
|
DistLayout::ThreeByFourEc84,
|
||||||
|
&[
|
||||||
|
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
|
||||||
|
("RUSTFS_HEAL_ENABLED", "true"),
|
||||||
|
("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "false"),
|
||||||
|
("RUSTFS_HEAL_MRF_ENABLE", "false"),
|
||||||
|
("RUSTFS_SCANNER_ENABLED", "false"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(dist.cluster.nodes.len(), EC84_NODE_COUNT);
|
||||||
|
assert_eq!(dist.cluster.topology.drives_per_node, EC84_DRIVES_PER_NODE);
|
||||||
|
|
||||||
|
let bucket = unique_bucket("healec84");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let mut expected = put_large_inventory(&writer, &bucket).await?;
|
||||||
|
let replaced_node = 1;
|
||||||
|
let replaced_drive_index = 2;
|
||||||
|
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
|
||||||
|
|
||||||
|
for item in &mut expected {
|
||||||
|
item.baseline = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
|
||||||
|
assert_ec84_geometry(&item.baseline, &item.key)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
|
||||||
|
let format_json = std::fs::read(&format_path)?;
|
||||||
|
let target_pid_before = dist.cluster.nodes[replaced_node]
|
||||||
|
.process
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("target process is absent before graceful restart")?
|
||||||
|
.id();
|
||||||
|
dist.cluster.stop_node_gracefully(replaced_node).await?;
|
||||||
|
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
||||||
|
std::fs::rename(&replaced_drive, &retired_drive)?;
|
||||||
|
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path has no parent")?)?;
|
||||||
|
std::fs::write(&format_path, format_json)?;
|
||||||
|
assert_replaced_drive_empty(
|
||||||
|
&replaced_drive,
|
||||||
|
&bucket,
|
||||||
|
&expected.iter().map(|item| item.key.clone()).collect::<Vec<_>>(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let outage_key = "ec84/written-while-node-restarting.bin";
|
||||||
|
let outage_body = payload_for(outage_key, 10 * 1024 * 1024);
|
||||||
|
writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(outage_key)
|
||||||
|
.body(ByteStream::from(outage_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
dist.cluster.start_node(replaced_node).await?;
|
||||||
|
let target_pid_after = dist.cluster.nodes[replaced_node]
|
||||||
|
.process
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("target process is absent after restart")?
|
||||||
|
.id();
|
||||||
|
let heal_body =
|
||||||
|
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||||
|
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[0].url);
|
||||||
|
start_ec84_root_heal_when_control_ready(&heal_url, heal_body, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||||
|
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(120),
|
||||||
|
|| async {
|
||||||
|
for item in &expected {
|
||||||
|
let current = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
|
||||||
|
if !current.matches_manifest(&item.baseline) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outage = census_object_version_on_disk(&replaced_drive, &bucket, outage_key, None)?;
|
||||||
|
Ok(outage.is_complete()
|
||||||
|
&& outage.data_blocks == Some(EC84_DATA_BLOCKS)
|
||||||
|
&& outage.parity_blocks == Some(EC84_PARITY_BLOCKS))
|
||||||
|
},
|
||||||
|
"EC8+4 replacement drive rebuilt baseline and outage shards",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let inventory = expected
|
||||||
|
.iter()
|
||||||
|
.map(|item| (item.key.clone(), item.body.clone()))
|
||||||
|
.chain(std::iter::once((outage_key.to_string(), outage_body.clone())))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let expected_keys = inventory.keys().cloned().collect::<HashSet<_>>();
|
||||||
|
let mut node_listings = Vec::new();
|
||||||
|
for node_index in 0..dist.cluster.nodes.len() {
|
||||||
|
let client = dist.client(node_index)?;
|
||||||
|
assert_inventory(&client, &bucket, &inventory).await?;
|
||||||
|
let listing = client.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
let observed = listing
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|object| object.key().map(str::to_owned))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
|
||||||
|
let mut observed = observed.into_iter().collect::<Vec<_>>();
|
||||||
|
observed.sort();
|
||||||
|
node_listings.push(observed);
|
||||||
|
}
|
||||||
|
if let Some(context) = evidence_context {
|
||||||
|
write_scanner_heal_evidence(
|
||||||
|
context,
|
||||||
|
ScannerHealEvidencePayload {
|
||||||
|
dist: &dist,
|
||||||
|
bucket: &bucket,
|
||||||
|
expected: &expected,
|
||||||
|
outage_key,
|
||||||
|
outage_body: &outage_body,
|
||||||
|
replaced_drive: &replaced_drive,
|
||||||
|
pid_before: target_pid_before,
|
||||||
|
pid_after: target_pid_after,
|
||||||
|
node_listings,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cluster_heal_coordination_retry_is_exact() {
|
||||||
|
let retryable: Box<dyn std::error::Error + Send + Sync> =
|
||||||
|
"admin POST failed: 500 Internal Server Error cluster heal coordination unavailable".into();
|
||||||
|
assert!(is_cluster_heal_coordination_unavailable(retryable.as_ref()));
|
||||||
|
|
||||||
|
let other_internal: Box<dyn std::error::Error + Send + Sync> =
|
||||||
|
"admin POST failed: 500 Internal Server Error unrelated".into();
|
||||||
|
assert!(!is_cluster_heal_coordination_unavailable(other_internal.as_ref()));
|
||||||
|
|
||||||
|
let wrong_status: Box<dyn std::error::Error + Send + Sync> =
|
||||||
|
"admin POST failed: 503 Service Unavailable cluster heal coordination unavailable".into();
|
||||||
|
assert!(!is_cluster_heal_coordination_unavailable(wrong_status.as_ref()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ mod data_integrity_movement_test;
|
|||||||
mod expand_decommission_rebalance_test;
|
mod expand_decommission_rebalance_test;
|
||||||
mod extra_test;
|
mod extra_test;
|
||||||
mod harness;
|
mod harness;
|
||||||
|
mod heal_test;
|
||||||
mod object_lock_test;
|
mod object_lock_test;
|
||||||
mod observability_test;
|
mod observability_test;
|
||||||
mod replication_quota_test;
|
mod replication_quota_test;
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
|
|
||||||
use super::harness::{
|
use super::harness::{
|
||||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||||
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
|
decommission_status_json, put_inventory_retrying, rebalance_active, rebalance_status_json, retrying_get_equals, retrying_put,
|
||||||
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||||
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
|
wait_for_decommission_running_with_progress, wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||||
};
|
};
|
||||||
use crate::common::init_logging;
|
use crate::common::init_logging;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -67,7 +67,10 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
|
|||||||
assert_inventory(&live, &bucket, &inventory).await?;
|
assert_inventory(&live, &bucket, &inventory).await?;
|
||||||
|
|
||||||
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||||
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
// The status API reads persisted progress, whose first periodic save is
|
||||||
|
// after 30 seconds. A shorter run can remain at zero until completion.
|
||||||
|
// Require Started around the S3 operations and nonzero progress at completion.
|
||||||
|
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||||
retrying_put(
|
retrying_put(
|
||||||
&live,
|
&live,
|
||||||
&bucket,
|
&bucket,
|
||||||
@@ -84,11 +87,26 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
|
|||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.any(|object| object.key() == Some("during-rebalance.bin")),
|
||||||
|
"list during rebalance missed the newly written key"
|
||||||
|
);
|
||||||
let status = rebalance_status_json(&dist.cluster).await?;
|
let status = rebalance_status_json(&dist.cluster).await?;
|
||||||
if !rebalance_running_with_progress(&status, &rebalance_id)? {
|
if !rebalance_active(&status, &rebalance_id)? {
|
||||||
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
||||||
}
|
}
|
||||||
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||||
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
|
let after = dist.client(1)?;
|
||||||
|
assert_inventory(&after, &bucket, &inventory).await?;
|
||||||
|
for (key, body) in [
|
||||||
|
("during-decommission.bin", b"written-while-decommissioning".as_slice()),
|
||||||
|
("during-rebalance.bin", b"written-while-rebalancing".as_slice()),
|
||||||
|
] {
|
||||||
|
retrying_get_equals(&after, &bucket, key, body, Duration::from_secs(30)).await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
|
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
||||||
rustfs_binary_path,
|
init_logging, rustfs_binary_path,
|
||||||
};
|
};
|
||||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
@@ -60,6 +60,65 @@ mod tests {
|
|||||||
oracle: &'static str,
|
oracle: &'static str,
|
||||||
evidence: &'static str,
|
evidence: &'static str,
|
||||||
unclean_shutdown_marker: bool,
|
unclean_shutdown_marker: bool,
|
||||||
|
topology: EvidenceTopology,
|
||||||
|
storage_class_standard: Option<&'static str>,
|
||||||
|
erasure_set_drive_count: Option<usize>,
|
||||||
|
outage_target_manifest_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct EvidenceTopology {
|
||||||
|
nodes: usize,
|
||||||
|
drives_per_node: usize,
|
||||||
|
layout: EvidenceTopologyLayout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum EvidenceTopologyLayout {
|
||||||
|
SinglePool,
|
||||||
|
PerNodePools,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceTopology {
|
||||||
|
const fn new(nodes: usize, drives_per_node: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
nodes,
|
||||||
|
drives_per_node,
|
||||||
|
layout: EvidenceTopologyLayout::SinglePool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn per_node_pools(nodes: usize, drives_per_node: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
nodes,
|
||||||
|
drives_per_node,
|
||||||
|
layout: EvidenceTopologyLayout::PerNodePools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_drives(self) -> usize {
|
||||||
|
self.nodes * self.drives_per_node
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster_topology(self) -> ClusterTopology {
|
||||||
|
match self.layout {
|
||||||
|
EvidenceTopologyLayout::SinglePool => ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node),
|
||||||
|
EvidenceTopologyLayout::PerNodePools => {
|
||||||
|
ClusterTopology::per_node_pools(self.drives_per_node, (0..self.nodes).map(|node| vec![node]).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_count(self) -> usize {
|
||||||
|
match self.layout {
|
||||||
|
EvidenceTopologyLayout::SinglePool => 1,
|
||||||
|
EvidenceTopologyLayout::PerNodePools => self.nodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_count(self, erasure_set_drive_count: Option<usize>) -> usize {
|
||||||
|
self.total_drives() / erasure_set_drive_count.unwrap_or_else(|| self.total_drives())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
@@ -67,6 +126,10 @@ mod tests {
|
|||||||
oracle: "background-target-restart.json",
|
oracle: "background-target-restart.json",
|
||||||
evidence: "process-restart",
|
evidence: "process-restart",
|
||||||
unclean_shutdown_marker: false,
|
unclean_shutdown_marker: false,
|
||||||
|
topology: EvidenceTopology::new(4, 1),
|
||||||
|
storage_class_standard: None,
|
||||||
|
erasure_set_drive_count: None,
|
||||||
|
outage_target_manifest_required: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
@@ -74,6 +137,54 @@ mod tests {
|
|||||||
oracle: "background-target-crash.json",
|
oracle: "background-target-crash.json",
|
||||||
evidence: "process-crash-restart",
|
evidence: "process-crash-restart",
|
||||||
unclean_shutdown_marker: true,
|
unclean_shutdown_marker: true,
|
||||||
|
topology: EvidenceTopology::new(4, 1),
|
||||||
|
storage_class_standard: None,
|
||||||
|
erasure_set_drive_count: None,
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_RESTART_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-restart-ec8-4",
|
||||||
|
oracle: "background-target-restart-ec8-4.json",
|
||||||
|
evidence: "process-restart",
|
||||||
|
unclean_shutdown_marker: false,
|
||||||
|
topology: EvidenceTopology::new(3, 4),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_CRASH_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-crash-ec8-4",
|
||||||
|
oracle: "background-target-crash-ec8-4.json",
|
||||||
|
evidence: "process-crash-restart",
|
||||||
|
unclean_shutdown_marker: true,
|
||||||
|
topology: EvidenceTopology::new(3, 4),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_RESTART_EC84_MULTI_SET_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-restart-ec8-4-multi-set",
|
||||||
|
oracle: "background-target-restart-ec8-4-multi-set.json",
|
||||||
|
evidence: "process-restart",
|
||||||
|
unclean_shutdown_marker: false,
|
||||||
|
topology: EvidenceTopology::new(3, 8),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_CRASH_EC84_MULTI_POOL_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-crash-ec8-4-multi-pool",
|
||||||
|
oracle: "background-target-crash-ec8-4-multi-pool.json",
|
||||||
|
evidence: "process-crash-restart",
|
||||||
|
unclean_shutdown_marker: true,
|
||||||
|
topology: EvidenceTopology::per_node_pools(3, 12),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RestartEvidenceContext {
|
struct RestartEvidenceContext {
|
||||||
@@ -332,11 +443,10 @@ mod tests {
|
|||||||
|
|
||||||
// Healing may rewrite non-identity bookkeeping in xl.meta. The census
|
// Healing may rewrite non-identity bookkeeping in xl.meta. The census
|
||||||
// therefore compares the canonical selected metadata fields plus every
|
// therefore compares the canonical selected metadata fields plus every
|
||||||
// physical shard, while the payload seed makes object mix-ups observable.
|
// physical shard.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct PhysicalObjectManifest {
|
struct PhysicalObjectManifest {
|
||||||
key: String,
|
key: String,
|
||||||
payload_seed: u8,
|
|
||||||
shard_census: VersionShardCensus,
|
shard_census: VersionShardCensus,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,6 +1083,46 @@ mod tests {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(420),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(420),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(600),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84MultiSet),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_across_multi_pool_after_background_target_crash()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(600),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84MultiPool),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||||
{
|
{
|
||||||
@@ -1010,29 +1160,48 @@ mod tests {
|
|||||||
IsolatedTargetRestart,
|
IsolatedTargetRestart,
|
||||||
BackgroundTargetRestart,
|
BackgroundTargetRestart,
|
||||||
BackgroundTargetCrash,
|
BackgroundTargetCrash,
|
||||||
|
BackgroundTargetRestartEc84,
|
||||||
|
BackgroundTargetCrashEc84,
|
||||||
|
BackgroundTargetRestartEc84MultiSet,
|
||||||
|
BackgroundTargetCrashEc84MultiPool,
|
||||||
BackgroundCoordinatorRestart,
|
BackgroundCoordinatorRestart,
|
||||||
TargetEndpointBlackhole,
|
TargetEndpointBlackhole,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let server_binary = rustfs_binary_path();
|
let server_binary = rustfs_binary_path();
|
||||||
let evidence_run = match scenario {
|
let evidence_case = match scenario {
|
||||||
InterruptionScenario::BackgroundTargetRestart => {
|
InterruptionScenario::BackgroundTargetRestart => Some(BACKGROUND_TARGET_RESTART_EVIDENCE),
|
||||||
restart_evidence_run(&server_binary, BACKGROUND_TARGET_RESTART_EVIDENCE)?
|
InterruptionScenario::BackgroundTargetCrash => Some(BACKGROUND_TARGET_CRASH_EVIDENCE),
|
||||||
}
|
InterruptionScenario::BackgroundTargetRestartEc84 => Some(BACKGROUND_TARGET_RESTART_EC84_EVIDENCE),
|
||||||
InterruptionScenario::BackgroundTargetCrash => {
|
InterruptionScenario::BackgroundTargetCrashEc84 => Some(BACKGROUND_TARGET_CRASH_EC84_EVIDENCE),
|
||||||
restart_evidence_run(&server_binary, BACKGROUND_TARGET_CRASH_EVIDENCE)?
|
InterruptionScenario::BackgroundTargetRestartEc84MultiSet => Some(BACKGROUND_TARGET_RESTART_EC84_MULTI_SET_EVIDENCE),
|
||||||
}
|
InterruptionScenario::BackgroundTargetCrashEc84MultiPool => Some(BACKGROUND_TARGET_CRASH_EC84_MULTI_POOL_EVIDENCE),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let evidence_run = match evidence_case {
|
||||||
|
Some(case) => restart_evidence_run(&server_binary, case)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
let mut evidence_objects = Vec::new();
|
let mut evidence_objects = Vec::new();
|
||||||
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
||||||
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
||||||
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
||||||
InterruptionScenario::BackgroundTargetCrash => (true, 1, "background_target_crash"),
|
InterruptionScenario::BackgroundTargetCrash => (true, 1, "background_target_crash"),
|
||||||
|
InterruptionScenario::BackgroundTargetRestartEc84 => (true, 1, "background_target_restart_ec8_4"),
|
||||||
|
InterruptionScenario::BackgroundTargetCrashEc84 => (true, 1, "background_target_crash_ec8_4"),
|
||||||
|
InterruptionScenario::BackgroundTargetRestartEc84MultiSet => (true, 1, "background_target_restart_ec8_4_multi_set"),
|
||||||
|
InterruptionScenario::BackgroundTargetCrashEc84MultiPool => (true, 1, "background_target_crash_ec8_4_multi_pool"),
|
||||||
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
|
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
|
||||||
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
|
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
|
||||||
};
|
};
|
||||||
|
let topology = evidence_case
|
||||||
|
.map(|case| case.topology)
|
||||||
|
.unwrap_or_else(|| EvidenceTopology::new(4, 1));
|
||||||
|
let erasure_set_drive_count = evidence_case
|
||||||
|
.and_then(|case| case.erasure_set_drive_count)
|
||||||
|
.unwrap_or_else(|| topology.total_drives());
|
||||||
|
let outage_target_manifest_required = evidence_case.map(|case| case.outage_target_manifest_required).unwrap_or(true);
|
||||||
init_logging();
|
init_logging();
|
||||||
info!(
|
info!(
|
||||||
event = "heal_interruption_started",
|
event = "heal_interruption_started",
|
||||||
@@ -1044,9 +1213,17 @@ mod tests {
|
|||||||
"Starting root-heal interruption test"
|
"Starting root-heal interruption test"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology.cluster_topology()).await?;
|
||||||
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
|
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
|
||||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
||||||
|
if let Some(storage_class) = evidence_case.and_then(|case| case.storage_class_standard) {
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", storage_class);
|
||||||
|
}
|
||||||
|
if let Some(erasure_set_drive_count) = evidence_case.and_then(|case| case.erasure_set_drive_count) {
|
||||||
|
cluster.set_env("RUSTFS_ERASURE_SET_DRIVE_COUNT", erasure_set_drive_count.to_string());
|
||||||
|
}
|
||||||
|
// Capture physical baselines after the PUT rename fanout has drained.
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
// Heal control uses the first lexicographically sorted grid host.
|
// Heal control uses the first lexicographically sorted grid host.
|
||||||
// Keep that coordinator distinct from the remote target at index 1.
|
// Keep that coordinator distinct from the remote target at index 1.
|
||||||
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
|
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
|
||||||
@@ -1095,11 +1272,22 @@ mod tests {
|
|||||||
.and_then(|value| value.parse::<usize>().ok())
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
.unwrap_or(4 * 1024 * 1024)
|
.unwrap_or(4 * 1024 * 1024)
|
||||||
.clamp(1024 * 1024, 16 * 1024 * 1024);
|
.clamp(1024 * 1024, 16 * 1024 * 1024);
|
||||||
|
let mut created_online_objects = Vec::with_capacity(online_object_count);
|
||||||
let mut expected_manifests = Vec::with_capacity(online_object_count);
|
let mut expected_manifests = Vec::with_capacity(online_object_count);
|
||||||
let mut unclean_shutdown_marker_observed = None;
|
let mut unclean_shutdown_marker_observed = None;
|
||||||
for index in 0..online_object_count {
|
let mut attempt_count = 0usize;
|
||||||
let key = format!("cluster/online/object-{index:04}.bin");
|
let max_online_attempts = online_object_count.saturating_mul(topology.total_drives().max(1));
|
||||||
let payload_seed = u8::try_from(index + 1).expect("clamped object count must fit in u8");
|
while expected_manifests.len() < online_object_count {
|
||||||
|
if attempt_count >= max_online_attempts {
|
||||||
|
return Err(format!(
|
||||||
|
"target replacement drive held only {}/{} baseline object shards after {attempt_count} writes",
|
||||||
|
expected_manifests.len(),
|
||||||
|
online_object_count
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let key = format!("cluster/online/object-{attempt_count:04}.bin");
|
||||||
|
let payload_seed = ((attempt_count % 251) + 1) as u8;
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
clients[0]
|
clients[0]
|
||||||
@@ -1111,6 +1299,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await??;
|
.await??;
|
||||||
let shard_census = census_object_version_on_disk(&replaced_disk, bucket, &key, None)?;
|
let shard_census = census_object_version_on_disk(&replaced_disk, bucket, &key, None)?;
|
||||||
|
if !shard_census.has_xl_meta {
|
||||||
|
timeout(Duration::from_secs(30), clients[0].delete_object().bucket(bucket).key(&key).send()).await??;
|
||||||
|
attempt_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
assert!(
|
assert!(
|
||||||
shard_census.is_complete(),
|
shard_census.is_complete(),
|
||||||
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
|
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
|
||||||
@@ -1119,11 +1312,9 @@ mod tests {
|
|||||||
!shard_census.expected_part_numbers.is_empty(),
|
!shard_census.expected_part_numbers.is_empty(),
|
||||||
"chaos objects must use physical part shards rather than inline data: {shard_census:?}"
|
"chaos objects must use physical part shards rather than inline data: {shard_census:?}"
|
||||||
);
|
);
|
||||||
expected_manifests.push(PhysicalObjectManifest {
|
created_online_objects.push((key.clone(), payload_seed));
|
||||||
key,
|
expected_manifests.push(PhysicalObjectManifest { key, shard_census });
|
||||||
payload_seed,
|
attempt_count += 1;
|
||||||
shard_census,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let expected_pool_metadata = if background_enabled {
|
let expected_pool_metadata = if background_enabled {
|
||||||
@@ -1169,31 +1360,48 @@ mod tests {
|
|||||||
if node_index == 1 {
|
if node_index == 1 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let census = census_object_version_on_disk(Path::new(&node.data_dir), bucket, outage_key, None)?;
|
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
|
||||||
|
let census = census_object_version_on_disk(Path::new(drive), bucket, outage_key, None)?;
|
||||||
|
if !census.has_xl_meta {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
census.is_complete(),
|
||||||
|
"online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}"
|
||||||
|
);
|
||||||
|
let erasure_index = census.erasure_index.ok_or_else(|| {
|
||||||
|
format!("online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}")
|
||||||
|
})?;
|
||||||
|
assert!(
|
||||||
|
(1..=erasure_set_drive_count).contains(&erasure_index),
|
||||||
|
"online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
outage_peer_erasure_indices.insert(erasure_index),
|
||||||
|
"outage-object erasure index {erasure_index} is duplicated across online drives"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!outage_peer_erasure_indices.is_empty() && outage_peer_erasure_indices.len() <= erasure_set_drive_count,
|
||||||
|
"outage-object must occupy one non-empty erasure set"
|
||||||
|
);
|
||||||
|
if outage_target_manifest_required {
|
||||||
|
let min_online_data_shards = erasure_set_drive_count.saturating_sub(4);
|
||||||
assert!(
|
assert!(
|
||||||
census.is_complete(),
|
outage_peer_erasure_indices.len() >= min_online_data_shards,
|
||||||
"online node {node_index} must hold a complete outage-object shard: {census:?}"
|
"online drives in the selected erasure set must retain at least the EC data quorum"
|
||||||
);
|
);
|
||||||
let erasure_index = census
|
}
|
||||||
.erasure_index
|
let missing_outage_erasure_indices = (1..=erasure_set_drive_count)
|
||||||
.ok_or_else(|| format!("online node {node_index} outage-object shard has no erasure index: {census:?}"))?;
|
.filter(|index| !outage_peer_erasure_indices.contains(index))
|
||||||
assert!(
|
.collect::<HashSet<_>>();
|
||||||
(1..=cluster.nodes.len()).contains(&erasure_index),
|
if outage_target_manifest_required {
|
||||||
"online node {node_index} outage-object erasure index is out of range: {census:?}"
|
assert!(
|
||||||
);
|
!missing_outage_erasure_indices.is_empty(),
|
||||||
assert!(
|
"the stopped target must account for at least one missing outage-object erasure index"
|
||||||
outage_peer_erasure_indices.insert(erasure_index),
|
|
||||||
"outage-object erasure index {erasure_index} is duplicated across online nodes"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
|
||||||
outage_peer_erasure_indices.len(),
|
|
||||||
cluster.nodes.len().saturating_sub(1),
|
|
||||||
"every online node must contribute one unique outage-object erasure index"
|
|
||||||
);
|
|
||||||
let expected_outage_target_erasure_index = (1..=cluster.nodes.len())
|
|
||||||
.find(|index| !outage_peer_erasure_indices.contains(index))
|
|
||||||
.ok_or("online outage-object shards leave no erasure index for the replacement target")?;
|
|
||||||
|
|
||||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||||
if !background_enabled {
|
if !background_enabled {
|
||||||
@@ -1463,7 +1671,12 @@ mod tests {
|
|||||||
"Restored target endpoint forwarding"
|
"Restored target endpoint forwarding"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if scenario == InterruptionScenario::BackgroundTargetRestart {
|
if matches!(
|
||||||
|
scenario,
|
||||||
|
InterruptionScenario::BackgroundTargetRestart
|
||||||
|
| InterruptionScenario::BackgroundTargetRestartEc84
|
||||||
|
| InterruptionScenario::BackgroundCoordinatorRestart
|
||||||
|
) {
|
||||||
cluster.stop_node_gracefully(interruption_node).await?;
|
cluster.stop_node_gracefully(interruption_node).await?;
|
||||||
} else {
|
} else {
|
||||||
cluster.stop_node(interruption_node)?;
|
cluster.stop_node(interruption_node)?;
|
||||||
@@ -1485,7 +1698,12 @@ mod tests {
|
|||||||
if background_enabled {
|
if background_enabled {
|
||||||
let marker_exists = unclean_shutdown_marker.is_file();
|
let marker_exists = unclean_shutdown_marker.is_file();
|
||||||
unclean_shutdown_marker_observed = Some(marker_exists);
|
unclean_shutdown_marker_observed = Some(marker_exists);
|
||||||
let expected_marker = !matches!(scenario, InterruptionScenario::BackgroundTargetRestart);
|
let expected_marker = matches!(
|
||||||
|
scenario,
|
||||||
|
InterruptionScenario::BackgroundTargetCrash
|
||||||
|
| InterruptionScenario::BackgroundTargetCrashEc84
|
||||||
|
| InterruptionScenario::BackgroundTargetCrashEc84MultiPool
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
marker_exists == expected_marker,
|
marker_exists == expected_marker,
|
||||||
"background restart/crash lane observed unexpected unclean-shutdown marker state"
|
"background restart/crash lane observed unexpected unclean-shutdown marker state"
|
||||||
@@ -1524,9 +1742,10 @@ mod tests {
|
|||||||
.unwrap_or(180);
|
.unwrap_or(180);
|
||||||
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
||||||
loop {
|
loop {
|
||||||
if metadata_count(&replaced_disk, bucket, &expected_manifests) == expected_manifests.len()
|
let baseline_recovered = metadata_count(&replaced_disk, bucket, &expected_manifests) == expected_manifests.len();
|
||||||
&& object_metadata_exists_on_disk(&replaced_disk, bucket, outage_key)
|
let outage_recovered =
|
||||||
{
|
!outage_target_manifest_required || object_metadata_exists_on_disk(&replaced_disk, bucket, outage_key);
|
||||||
|
if baseline_recovered && outage_recovered {
|
||||||
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
||||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||||
let pool_metadata_matches = match &expected_pool_metadata {
|
let pool_metadata_matches = match &expected_pool_metadata {
|
||||||
@@ -1536,7 +1755,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
None => true,
|
None => true,
|
||||||
};
|
};
|
||||||
if matching == expected_manifests.len() && outage_census.is_complete() && pool_metadata_matches {
|
if matching == expected_manifests.len()
|
||||||
|
&& (!outage_target_manifest_required || outage_census.is_complete())
|
||||||
|
&& pool_metadata_matches
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1581,34 +1803,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||||
assert!(
|
if outage_target_manifest_required {
|
||||||
outage_census.is_complete(),
|
assert!(
|
||||||
"outage object must have a complete target shard: {outage_census:?}"
|
outage_census.is_complete(),
|
||||||
);
|
"outage object must have a complete target shard: {outage_census:?}"
|
||||||
assert_eq!(
|
);
|
||||||
outage_census.erasure_index,
|
assert_eq!(
|
||||||
Some(expected_outage_target_erasure_index),
|
outage_census
|
||||||
"the outage object must be rebuilt into its own missing erasure slot"
|
.erasure_index
|
||||||
);
|
.filter(|index| missing_outage_erasure_indices.contains(index)),
|
||||||
|
outage_census.erasure_index,
|
||||||
|
"the outage object must be rebuilt into one of the stopped node's missing erasure slots"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(cycle_end) = scanner_cycle_floor {
|
if let Some(cycle_end) = scanner_cycle_floor {
|
||||||
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
|
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut expected_keys = expected_manifests
|
let mut expected_keys = created_online_objects
|
||||||
.iter()
|
.iter()
|
||||||
.map(|manifest| manifest.key.clone())
|
.map(|(key, _)| key.clone())
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
assert!(expected_keys.insert(outage_key.to_string()));
|
assert!(expected_keys.insert(outage_key.to_string()));
|
||||||
let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
||||||
|
|
||||||
let target_client = cluster.create_s3_client(1)?;
|
let target_client = cluster.create_s3_client(1)?;
|
||||||
for expected in &expected_manifests {
|
for (key, payload_seed) in &created_online_objects {
|
||||||
let response = target_client.get_object().bucket(bucket).key(&expected.key).send().await?;
|
let response = target_client.get_object().bucket(bucket).key(key).send().await?;
|
||||||
let actual = response.body.collect().await?.into_bytes();
|
let actual = response.body.collect().await?.into_bytes();
|
||||||
let expected_body = deterministic_object_body(object_size_bytes, expected.payload_seed);
|
let expected_body = deterministic_object_body(object_size_bytes, *payload_seed);
|
||||||
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {}", expected.key);
|
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {key}");
|
||||||
if evidence_run.is_some() {
|
if evidence_run.is_some()
|
||||||
|
&& let Some(expected) = expected_manifests
|
||||||
|
.iter()
|
||||||
|
.find(|manifest| manifest.key.as_str() == key.as_str())
|
||||||
|
{
|
||||||
evidence_objects.push(serde_json::json!({
|
evidence_objects.push(serde_json::json!({
|
||||||
"key": expected.key, "version_id": expected.shard_census.version_id,
|
"key": expected.key, "version_id": expected.shard_census.version_id,
|
||||||
"expected_bytes": expected_body.len(), "actual_bytes": actual.len(),
|
"expected_bytes": expected_body.len(), "actual_bytes": actual.len(),
|
||||||
@@ -1651,33 +1881,22 @@ mod tests {
|
|||||||
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||||
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
|
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
|
||||||
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
|
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
|
||||||
|
if task_status["summary"].as_str() != Some("finished") {
|
||||||
|
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
||||||
|
}
|
||||||
if interruption_node == 0 {
|
if interruption_node == 0 {
|
||||||
// Admin tasks are process-local. Physical and queue convergence
|
// Restart recovery must finish the original durable root request.
|
||||||
// above establish recovery; a lost task must not report success.
|
|
||||||
assert_eq!(
|
|
||||||
task_status["summary"].as_str(),
|
|
||||||
Some("notFound"),
|
|
||||||
"interrupted task status: {task_status}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
task_status["detail"].as_str(),
|
|
||||||
Some("heal task not found or expired"),
|
|
||||||
"interrupted admin task must be explicitly unavailable: {task_status}"
|
|
||||||
);
|
|
||||||
info!(
|
info!(
|
||||||
event = "heal_interruption_recovered",
|
event = "heal_interruption_recovered",
|
||||||
component = "e2e_test",
|
component = "e2e_test",
|
||||||
subsystem = "heal",
|
subsystem = "heal",
|
||||||
interruption_node,
|
interruption_node,
|
||||||
interruption_kind,
|
interruption_kind,
|
||||||
task_state = "not_found",
|
task_state = "finished",
|
||||||
"Physical recovery completed after coordinator restart"
|
"Original root heal completed after coordinator restart"
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if task_status["summary"].as_str() != Some("finished") {
|
|
||||||
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(evidence_context) = evidence_run {
|
if let Some(evidence_context) = evidence_run {
|
||||||
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
||||||
@@ -1694,6 +1913,14 @@ mod tests {
|
|||||||
"binary_sha256": evidence_context.run.binary.sha256,
|
"binary_sha256": evidence_context.run.binary.sha256,
|
||||||
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
||||||
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
||||||
|
"erasure_set_drive_count": erasure_set_drive_count,
|
||||||
|
"sets": topology.set_count(evidence_context.case.erasure_set_drive_count),
|
||||||
|
"pools": topology.pool_count(),
|
||||||
|
"outage_target_manifest_required": outage_target_manifest_required,
|
||||||
|
"distributed_ec_invalidation": true,
|
||||||
|
"peer_count": cluster.nodes.len(),
|
||||||
|
"same_window_remote_proof": true,
|
||||||
|
"all_peers_bound_to_generation_window": true,
|
||||||
"pid_before": target_pid, "pid_after": restarted_pid,
|
"pid_before": target_pid, "pid_after": restarted_pid,
|
||||||
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
|
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
|
||||||
"objects": evidence_objects, "node_listings": node_listings,
|
"objects": evidence_objects, "node_listings": node_listings,
|
||||||
|
|||||||
@@ -2234,7 +2234,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
||||||
let prefix = "transition/distributed-admission/";
|
let prefix = "transition/distributed-admission/";
|
||||||
hot_client.create_bucket().bucket(&bucket).send().await?;
|
hot_client.create_bucket().bucket(&bucket).send().await?;
|
||||||
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
|
||||||
for index in 0u8..64 {
|
for index in 0u8..64 {
|
||||||
let key = format!("{prefix}object-{index:02}.bin");
|
let key = format!("{prefix}object-{index:02}.bin");
|
||||||
hot_client
|
hot_client
|
||||||
@@ -2245,6 +2244,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
||||||
|
|
||||||
let (node0, node1) = tokio::join!(
|
let (node0, node1) = tokio::join!(
|
||||||
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
||||||
|
|||||||
@@ -59,6 +59,36 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
// A missing key in the healthy store is a client error, unlike a store outage.
|
||||||
|
let missing_key_object = "test-missing-kms-key";
|
||||||
|
let missing_key_error = s3_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(missing_key_object)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"must not be published"))
|
||||||
|
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||||
|
.ssekms_key_id("rustfs-e2e-test-missing-key")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unknown key in a healthy Local KMS store must reject the write");
|
||||||
|
assert_eq!(missing_key_error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||||
|
assert_eq!(
|
||||||
|
missing_key_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("KMS.NotFoundException")
|
||||||
|
);
|
||||||
|
let missing_key_absence = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(missing_key_object)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a write rejected by a missing KMS key must not publish an object");
|
||||||
|
assert_eq!(missing_key_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
missing_key_absence.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey")
|
||||||
|
);
|
||||||
|
|
||||||
// Temporarily rename the key directory to simulate unavailability
|
// Temporarily rename the key directory to simulate unavailability
|
||||||
info!("🔧 Simulating key directory unavailability");
|
info!("🔧 Simulating key directory unavailability");
|
||||||
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
|
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
|
||||||
@@ -79,10 +109,10 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
||||||
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
Some("InternalError")
|
Some("ServiceUnavailable")
|
||||||
);
|
);
|
||||||
let unavailable_absence = s3_client
|
let unavailable_absence = s3_client
|
||||||
.get_object()
|
.get_object()
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod storage_metric_ownership_test;
|
||||||
|
|
||||||
mod reliant;
|
mod reliant;
|
||||||
mod storage_api;
|
mod storage_api;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
//! Regression coverage for anonymous access on multipart control APIs.
|
//! Regression coverage for anonymous access on multipart control APIs.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use crate::kms::common::LocalKMSTestEnvironment;
|
||||||
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
|
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
|
||||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
||||||
@@ -1465,10 +1466,10 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
|
|||||||
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let master_key = local_sse_master_key_value();
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
|
kms_env.wait_for_kms_ready().await?;
|
||||||
.await?;
|
let env = &kms_env.base_env;
|
||||||
|
|
||||||
let bucket = "anon-post-default-sse-kms";
|
let bucket = "anon-post-default-sse-kms";
|
||||||
let object_key = "post-default-sse-kms-object.txt";
|
let object_key = "post-default-sse-kms-object.txt";
|
||||||
@@ -1484,7 +1485,7 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
|
|||||||
.apply_server_side_encryption_by_default(
|
.apply_server_side_encryption_by_default(
|
||||||
ServerSideEncryptionByDefault::builder()
|
ServerSideEncryptionByDefault::builder()
|
||||||
.sse_algorithm(ServerSideEncryption::AwsKms)
|
.sse_algorithm(ServerSideEncryption::AwsKms)
|
||||||
.kms_master_key_id("test-key")
|
.kms_master_key_id(default_key_id)
|
||||||
.build()
|
.build()
|
||||||
.expect("default encryption rule should build"),
|
.expect("default encryption rule should build"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use aws_sdk_s3::Client;
|
|||||||
use aws_sdk_s3::error::SdkError;
|
use aws_sdk_s3::error::SdkError;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::Barrier;
|
use tokio::sync::Barrier;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
@@ -25,6 +26,307 @@ const KEY: &str = "thumb/79/concurrent-overwrite.jpg";
|
|||||||
|
|
||||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
async fn assert_degraded_cluster_publication_guard_errors_are_retryable() -> TestResult {
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUST_LOG", "warn");
|
||||||
|
cluster.start().await?;
|
||||||
|
cluster.create_test_bucket(BUCKET).await?;
|
||||||
|
let clients: Vec<_> = cluster
|
||||||
|
.create_all_clients()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|client| {
|
||||||
|
Client::from_conf(
|
||||||
|
client
|
||||||
|
.config()
|
||||||
|
.to_builder()
|
||||||
|
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alive in (1..=4).rev() {
|
||||||
|
if alive < 4 {
|
||||||
|
cluster.stop_node(alive)?;
|
||||||
|
}
|
||||||
|
for (node, client) in clients.iter().take(alive).enumerate() {
|
||||||
|
let key = format!("publication-put-{alive}-{node}");
|
||||||
|
let put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(&key)
|
||||||
|
.body(Bytes::from_static(b"publication guard regression").into())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
if alive >= 3 {
|
||||||
|
put?;
|
||||||
|
} else {
|
||||||
|
let err = put.expect_err("PUT must reject writes without a write quorum");
|
||||||
|
assert_eq!(
|
||||||
|
err.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(503),
|
||||||
|
"PUT with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
err.as_service_error().and_then(|error| error.meta().code()),
|
||||||
|
Some("ServiceUnavailable"),
|
||||||
|
"PUT with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let multipart_key = format!("publication-multipart-{alive}-{node}");
|
||||||
|
let multipart = client
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(&multipart_key)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
if alive >= 3 {
|
||||||
|
let upload = multipart?;
|
||||||
|
let upload_id = upload
|
||||||
|
.upload_id()
|
||||||
|
.expect("successful multipart initialization must return an upload ID");
|
||||||
|
client
|
||||||
|
.abort_multipart_upload()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(&multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
let err = multipart.expect_err("multipart initialization must reject writes without a write quorum");
|
||||||
|
assert_eq!(
|
||||||
|
err.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(503),
|
||||||
|
"CreateMultipartUpload with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
err.as_service_error().and_then(|error| error.meta().code()),
|
||||||
|
Some("ServiceUnavailable"),
|
||||||
|
"CreateMultipartUpload with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.stop();
|
||||||
|
cluster.start().await?;
|
||||||
|
for client in &clients {
|
||||||
|
for alive in [1, 3, 4] {
|
||||||
|
for node in 0..alive {
|
||||||
|
let key = format!("publication-put-{alive}-{node}");
|
||||||
|
let get = client.get_object().bucket(BUCKET).key(key).send().await;
|
||||||
|
if alive >= 3 {
|
||||||
|
assert_eq!(
|
||||||
|
get?.body.collect().await?.into_bytes().as_ref(),
|
||||||
|
b"publication guard regression",
|
||||||
|
"acknowledged writes must survive restart"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let err = get.expect_err("a rejected publication guard must not publish an object");
|
||||||
|
assert_eq!(err.as_service_error().and_then(|error| error.meta().code()), Some("NoSuchKey"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let uploads = client.list_multipart_uploads().bucket(BUCKET).send().await?;
|
||||||
|
assert!(
|
||||||
|
uploads
|
||||||
|
.uploads()
|
||||||
|
.iter()
|
||||||
|
.all(|upload| upload.key() != Some("publication-multipart-1-0")),
|
||||||
|
"a rejected publication guard must not publish a multipart upload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_quorum_object_body(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
|
||||||
|
let body = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), expected, "quorum read returned incorrect contents for {key}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_quorum_read_admission(clients: &[Client], bucket: &str) -> TestResult {
|
||||||
|
// SIGKILL can orphan a granted lease. Wait for shared metadata-lock
|
||||||
|
// admission before asserting the stable quorum boundary; cold bodies
|
||||||
|
// remain unread throughout this readiness probe.
|
||||||
|
let deadline =
|
||||||
|
tokio::time::Instant::now() + rustfs_lock::fast_lock::DEFAULT_LOCK_TIMEOUT + std::time::Duration::from_secs(15);
|
||||||
|
loop {
|
||||||
|
let mut ready = true;
|
||||||
|
for client in clients {
|
||||||
|
for key in ["warm-small", "warm-large"] {
|
||||||
|
match client.head_object().bucket(bucket).key(key).send().await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => {
|
||||||
|
ready = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ready {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!("read quorum did not become available after lease convergence for {bucket}").into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResult {
|
||||||
|
crate::common::init_logging();
|
||||||
|
|
||||||
|
for (node_count, parity) in [(4, 2), (6, 3), (6, 2)] {
|
||||||
|
let read_quorum = node_count - parity;
|
||||||
|
let write_quorum = read_quorum + usize::from(read_quorum == parity);
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::new(node_count).await?;
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||||
|
// Wait for every seed fanout before removing any physical shard.
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUST_LOG", "warn,rustfs_lock=debug");
|
||||||
|
cluster.start().await?;
|
||||||
|
|
||||||
|
let clients = cluster
|
||||||
|
.create_all_clients()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|client| {
|
||||||
|
Client::from_conf(
|
||||||
|
client
|
||||||
|
.config()
|
||||||
|
.to_builder()
|
||||||
|
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let bucket = format!("read-quorum-{node_count}-{parity}");
|
||||||
|
clients[0].create_bucket().bucket(&bucket).send().await?;
|
||||||
|
let small = b"read quorum is derived from the erasure layout".to_vec();
|
||||||
|
let large = (0..1_048_576)
|
||||||
|
.map(|index| u8::try_from(index % 251).expect("bounded payload byte"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for (key, body) in [
|
||||||
|
("warm-small", &small),
|
||||||
|
("warm-large", &large),
|
||||||
|
("cold-small", &small),
|
||||||
|
("cold-large", &large),
|
||||||
|
("below-quorum", &large),
|
||||||
|
] {
|
||||||
|
clients[node_count - 1]
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(Bytes::copy_from_slice(body).into())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for node in &cluster.nodes {
|
||||||
|
for key in ["warm-small", "warm-large", "cold-small", "cold-large", "below-quorum"] {
|
||||||
|
let census =
|
||||||
|
crate::chaos::census_object_version_on_disk(std::path::Path::new(&node.data_dir), &bucket, key, None)?;
|
||||||
|
assert!(census.is_complete(), "seed shard must be complete before fault injection: {census:?}");
|
||||||
|
assert_eq!(census.data_blocks, Some(read_quorum));
|
||||||
|
assert_eq!(census.parity_blocks, Some(parity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for client in &clients {
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for offline_node in (read_quorum..node_count).rev() {
|
||||||
|
cluster.stop_node(offline_node)?;
|
||||||
|
wait_for_quorum_read_admission(&clients[..offline_node], &bucket).await?;
|
||||||
|
for client in clients.iter().take(offline_node) {
|
||||||
|
client.head_bucket().bucket(&bucket).send().await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise more than the five-second positive bucket-validation TTL.
|
||||||
|
// Every sample must succeed; polling must not hide a transient failure.
|
||||||
|
let validation_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(6);
|
||||||
|
loop {
|
||||||
|
for client in clients.iter().take(read_quorum) {
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
let listing = client.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
for key in ["warm-small", "warm-large", "cold-small", "cold-large", "below-quorum"] {
|
||||||
|
assert!(listing.contents().iter().any(|entry| entry.key() == Some(key)), "listing omitted {key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= validation_deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
for client in clients.iter().take(read_quorum) {
|
||||||
|
assert_quorum_object_body(client, &bucket, "cold-small", &small).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "cold-large", &large).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let write = clients[0]
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key("quorum-write")
|
||||||
|
.body(Bytes::copy_from_slice(&small).into())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
if read_quorum >= write_quorum {
|
||||||
|
write?;
|
||||||
|
} else {
|
||||||
|
let error = write.expect_err("a read quorum must not authorize a write that needs more votes");
|
||||||
|
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("ServiceUnavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.stop_node(read_quorum - 1)?;
|
||||||
|
for client in clients.iter().take(read_quorum - 1) {
|
||||||
|
match client.get_object().bucket(&bucket).key("below-quorum").send().await {
|
||||||
|
Ok(response) => assert!(
|
||||||
|
response.body.collect().await.is_err(),
|
||||||
|
"fewer than {read_quorum} valid fragments must not reconstruct an uncached object"
|
||||||
|
),
|
||||||
|
Err(error) => assert_eq!(
|
||||||
|
error.as_service_error().and_then(|error| error.meta().code()),
|
||||||
|
Some("ServiceUnavailable"),
|
||||||
|
"a quorum loss must not be mistaken for a missing object"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in 0..read_quorum - 1 {
|
||||||
|
cluster.stop_node(node)?;
|
||||||
|
}
|
||||||
|
cluster.start().await?;
|
||||||
|
for client in &clients {
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "below-quorum", &large).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
|
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
|
||||||
client
|
client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -58,6 +360,9 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
|
|||||||
// Keep the regression focused on false quorum-loss errors, not ordinary lock
|
// Keep the regression focused on false quorum-loss errors, not ordinary lock
|
||||||
// wait exhaustion under a heavily contended same-key overwrite workload.
|
// wait exhaustion under a heavily contended same-key overwrite workload.
|
||||||
cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20");
|
cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20");
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
|
cluster.set_env("RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE", "false");
|
||||||
cluster.start().await?;
|
cluster.start().await?;
|
||||||
cluster.create_test_bucket(BUCKET).await?;
|
cluster.create_test_bucket(BUCKET).await?;
|
||||||
|
|
||||||
@@ -116,6 +421,181 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
|
|||||||
);
|
);
|
||||||
|
|
||||||
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
||||||
|
assert_node_readiness_tracks_quorum(&mut cluster).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_node_readiness_tracks_quorum(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
|
||||||
|
let clients: Vec<_> = cluster
|
||||||
|
.create_all_clients()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|client| {
|
||||||
|
Client::from_conf(
|
||||||
|
client
|
||||||
|
.config()
|
||||||
|
.to_builder()
|
||||||
|
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(Duration::from_secs(3))
|
||||||
|
.build()?;
|
||||||
|
let seed_key = "readiness-seed";
|
||||||
|
let seed_body = b"readiness quorum regression";
|
||||||
|
clients[0]
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(seed_key)
|
||||||
|
.body(Bytes::from_static(seed_body).into())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (phase, survivors) in [4, 3, 2, 1, 4].into_iter().enumerate() {
|
||||||
|
if phase == 4 {
|
||||||
|
cluster.stop();
|
||||||
|
cluster.start().await?;
|
||||||
|
} else if survivors < 4 {
|
||||||
|
cluster.stop_node(survivors)?;
|
||||||
|
}
|
||||||
|
let write_ready = survivors >= 3;
|
||||||
|
let read_quorum = survivors >= 2;
|
||||||
|
let expected_status = if write_ready { 200 } else { 503 };
|
||||||
|
for (idx, client) in clients.iter().enumerate().take(survivors) {
|
||||||
|
let url = &cluster.nodes[idx].url;
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
// Poll health before issuing S3 I/O: idle remote disk handles must
|
||||||
|
// not remain evidence of quorum after their host becomes unreachable.
|
||||||
|
let payload = loop {
|
||||||
|
let response = http.get(format!("{url}/health/ready")).send().await?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
if status == expected_status
|
||||||
|
&& payload["ready"] == write_ready
|
||||||
|
&& payload["details"]["storage"]["ready"] == write_ready
|
||||||
|
&& payload["details"]["storage"]["readQuorum"] == read_quorum
|
||||||
|
&& payload["details"]["storage"]["writeQuorum"] == write_ready
|
||||||
|
&& payload["details"]["poolMetadata"]["ready"] == true
|
||||||
|
&& payload["details"]["iam"]["ready"] == true
|
||||||
|
&& payload["details"]["lock"]["ready"] == write_ready
|
||||||
|
{
|
||||||
|
break payload;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "node {idx}, survivors={survivors}: HTTP {status}, {payload}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
};
|
||||||
|
assert_eq!(payload["details"]["storage"]["readinessScope"], "write_quorum_and_pool_metadata");
|
||||||
|
assert_eq!(payload["details"]["storage"]["source"], "local_runtime");
|
||||||
|
assert_eq!(
|
||||||
|
payload["details"]["storage"]["status"],
|
||||||
|
if write_ready { "connected" } else { "disconnected" }
|
||||||
|
);
|
||||||
|
if !write_ready {
|
||||||
|
assert!(
|
||||||
|
payload["degradedReasons"]
|
||||||
|
.as_array()
|
||||||
|
.expect("degraded reasons")
|
||||||
|
.iter()
|
||||||
|
.any(|reason| reason == "storage_and_lock_unavailable")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in ["/health/ready", "/minio/health/ready"] {
|
||||||
|
let head = http.head(format!("{url}{path}")).send().await?;
|
||||||
|
assert_eq!(head.status().as_u16(), expected_status, "HEAD {path}, survivors={survivors}");
|
||||||
|
assert!(head.bytes().await?.is_empty());
|
||||||
|
let response = http.get(format!("{url}{path}")).send().await?;
|
||||||
|
assert_eq!(response.status().as_u16(), expected_status);
|
||||||
|
let body: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(body["details"]["storage"], payload["details"]["storage"]);
|
||||||
|
assert_eq!(body["details"]["poolMetadata"], payload["details"]["poolMetadata"]);
|
||||||
|
}
|
||||||
|
let live = http.get(format!("{url}/health/live")).send().await?;
|
||||||
|
assert_eq!(live.status().as_u16(), 200);
|
||||||
|
assert!(live.json::<serde_json::Value>().await?.get("details").is_none());
|
||||||
|
for (path, storage_ready, scope) in [
|
||||||
|
("/minio/health/cluster", write_ready, "write_quorum_and_pool_metadata"),
|
||||||
|
("/minio/health/cluster/read", read_quorum, "read_quorum"),
|
||||||
|
] {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
// Cluster read/write reports have independent caches; allow
|
||||||
|
// each observation to expire before comparing stable states.
|
||||||
|
let body = loop {
|
||||||
|
let response = http.get(format!("{url}{path}")).send().await?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body: serde_json::Value = response.json().await?;
|
||||||
|
if status == expected_status
|
||||||
|
&& body["details"]["storage"]["ready"] == storage_ready
|
||||||
|
&& body["details"]["lock"]["ready"] == write_ready
|
||||||
|
{
|
||||||
|
break body;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "{path}, survivors={survivors}: HTTP {status}, {body}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
};
|
||||||
|
assert_eq!(body["details"]["storage"]["readinessScope"], scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
let put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(format!("readiness-phase-{phase}-node-{idx}"))
|
||||||
|
.body(Bytes::from_static(seed_body).into())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let put_status = if write_ready {
|
||||||
|
put.expect("a ready node must accept the PUT");
|
||||||
|
200
|
||||||
|
} else {
|
||||||
|
let error = put.expect_err("subquorum node must reject PUT");
|
||||||
|
assert!(
|
||||||
|
error.raw_response().is_some_and(|response| response.status().as_u16() >= 500),
|
||||||
|
"unexpected PUT failure: {error:?}"
|
||||||
|
);
|
||||||
|
error.raw_response().expect("PUT error response").status().as_u16()
|
||||||
|
};
|
||||||
|
let get = client.get_object().bucket(BUCKET).key(seed_key).send().await;
|
||||||
|
let get_status = match get {
|
||||||
|
Ok(object) => {
|
||||||
|
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), seed_body);
|
||||||
|
200
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
assert!(!write_ready, "GET must succeed on a ready cluster: {error:?}");
|
||||||
|
let status = error
|
||||||
|
.raw_response()
|
||||||
|
.expect("GET should have an HTTP response")
|
||||||
|
.status()
|
||||||
|
.as_u16();
|
||||||
|
assert!(status >= 500, "unexpected GET failure: {error:?}");
|
||||||
|
status
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let list = client.list_objects_v2().bucket(BUCKET).send().await;
|
||||||
|
let list_status = match list {
|
||||||
|
Ok(result) => {
|
||||||
|
assert!(result.contents().iter().any(|object| object.key() == Some(seed_key)));
|
||||||
|
200
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
assert!(!write_ready, "listing must succeed on a ready cluster: {error:?}");
|
||||||
|
let status = error
|
||||||
|
.raw_response()
|
||||||
|
.expect("LIST should have an HTTP response")
|
||||||
|
.status()
|
||||||
|
.as_u16();
|
||||||
|
assert!(status >= 500, "unexpected listing failure: {error:?}");
|
||||||
|
status
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"readiness matrix: survivors={survivors}, node={idx}, ready={write_ready}, read_quorum={read_quorum}, PUT={put_status}, GET={get_status}, LIST={list_status}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cluster.stop();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +605,8 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
|
|||||||
/// Before the fix, `map_namespace_lock_error` wrapped lock timeout/conflict errors as
|
/// Before the fix, `map_namespace_lock_error` wrapped lock timeout/conflict errors as
|
||||||
/// `StorageError::other(...)` → `StorageError::Io(...)`, which fell through to
|
/// `StorageError::other(...)` → `StorageError::Io(...)`, which fell through to
|
||||||
/// `S3ErrorCode::InternalError` (500) in the error mapping.
|
/// `S3ErrorCode::InternalError` (500) in the error mapping.
|
||||||
|
/// Also checks PUT and multipart initialization when node failures prevent
|
||||||
|
/// acquiring a table publication guard.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
|
async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
|
||||||
crate::common::init_logging();
|
crate::common::init_logging();
|
||||||
@@ -227,5 +709,6 @@ async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
||||||
Ok(())
|
cluster.stop();
|
||||||
|
assert_degraded_cluster_publication_guard_errors_are_retryable().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1137,7 +1137,12 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
|
|||||||
let miss = env.raw_get(bucket, miss_key).await?;
|
let miss = env.raw_get(bucket, miss_key).await?;
|
||||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||||
}
|
}
|
||||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
let (listed, _, _) = tokio::try_join!(
|
||||||
|
env.wait_local_listed(bucket, hit_key, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
|
||||||
|
)?;
|
||||||
|
assert!(listed);
|
||||||
|
|
||||||
let status = env.status_json(bucket).await?;
|
let status = env.status_json(bucket).await?;
|
||||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||||
|
|||||||
@@ -4235,6 +4235,16 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||||
</Rule>
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-and-tags</ID>
|
||||||
|
<Priority>135</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
<Rule>
|
<Rule>
|
||||||
<ID>matrix-disabled</ID>
|
<ID>matrix-disabled</ID>
|
||||||
<Priority>140</Priority>
|
<Priority>140</Priority>
|
||||||
@@ -4289,6 +4299,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
"matrix-prefix",
|
"matrix-prefix",
|
||||||
"matrix-tag",
|
"matrix-tag",
|
||||||
"matrix-disabled",
|
"matrix-disabled",
|
||||||
|
"matrix-and-tags",
|
||||||
"matrix-priority-high",
|
"matrix-priority-high",
|
||||||
"Priority>200",
|
"Priority>200",
|
||||||
"<Status>Disabled</Status>",
|
"<Status>Disabled</Status>",
|
||||||
@@ -4409,6 +4420,30 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
||||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||||
|
|
||||||
|
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
|
||||||
|
// the required tags is not admitted. Matching any single tag would push
|
||||||
|
// data to a destination the rule never selected (backlog#2366 P1-1), and
|
||||||
|
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
|
||||||
|
// writes, so a single-tag rule passing is not evidence for this.
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("and-tags/partial.txt")
|
||||||
|
.tagging("env=prod")
|
||||||
|
.body(ByteStream::from_static(b"one of two tags"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("and-tags/full.txt")
|
||||||
|
.tagging("env=prod&tier=gold")
|
||||||
|
.body(ByteStream::from_static(b"both tags"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
|
||||||
|
|
||||||
source_client
|
source_client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(source_bucket)
|
.bucket(source_bucket)
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Native OTLP -> Collector -> Prometheus contract, including a rolling upgrade.
|
||||||
|
|
||||||
|
use crate::common::RustFSTestClusterEnvironment;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
struct ToolProcess(Child);
|
||||||
|
impl Drop for ToolProcess {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.0.kill();
|
||||||
|
let _ = self.0.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_binary(name: &str) -> TestResult<PathBuf> {
|
||||||
|
let path = PathBuf::from(std::env::var(name).map_err(|_| format!("{name} must name a pinned executable"))?);
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!("{name} does not name a file: {}", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn free_port() -> TestResult<u16> {
|
||||||
|
Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_tool(binary: &Path, args: &[String], log: &Path) -> TestResult<ToolProcess> {
|
||||||
|
let log = File::create(log)?;
|
||||||
|
Ok(ToolProcess(
|
||||||
|
Command::new(binary)
|
||||||
|
.args(args)
|
||||||
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
||||||
|
.env_remove("HTTP_PROXY")
|
||||||
|
.env_remove("HTTPS_PROXY")
|
||||||
|
.stdout(Stdio::from(log.try_clone()?))
|
||||||
|
.stderr(Stdio::from(log))
|
||||||
|
.spawn()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query(client: &reqwest::Client, base: &str, expression: &str) -> TestResult<Value> {
|
||||||
|
let mut url = reqwest::Url::parse(&format!("{base}/api/v1/query"))?;
|
||||||
|
url.query_pairs_mut().append_pair("query", expression);
|
||||||
|
let result: Value = client.get(url).send().await?.error_for_status()?.json().await?;
|
||||||
|
if result["status"] != "success" {
|
||||||
|
return Err(format!("PromQL failed: {result}").into());
|
||||||
|
}
|
||||||
|
Ok(result["data"]["result"].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_count(client: &reqwest::Client, base: &str, selector: &str, expected: u64) -> TestResult {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(120);
|
||||||
|
loop {
|
||||||
|
let result = query(client, base, &format!("count({selector}) or vector(0)")).await;
|
||||||
|
if let Ok(rows) = &result
|
||||||
|
&& rows[0]["value"][1].as_str().and_then(|value| value.parse::<u64>().ok()) == Some(expected)
|
||||||
|
{
|
||||||
|
println!("PASS count={expected}: {selector}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!("expected {expected} for {selector}; last result: {result:?}").into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_dashboard_queries(client: &reqwest::Client, base: &str, observer: &str) -> TestResult {
|
||||||
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.docker/observability/grafana/dashboards/rustfs.json");
|
||||||
|
let dashboard: Value = serde_json::from_str(&fs::read_to_string(path)?)?;
|
||||||
|
for name in ["storage_cluster", "storage_observer"] {
|
||||||
|
let variable = dashboard["templating"]["list"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("dashboard variables")?
|
||||||
|
.iter()
|
||||||
|
.find(|variable| variable["name"] == name)
|
||||||
|
.ok_or("storage selection variable")?;
|
||||||
|
assert_eq!(variable["multi"], false, "storage views must select one {name}");
|
||||||
|
assert_eq!(variable["includeAll"], false, "storage views must select one {name}");
|
||||||
|
}
|
||||||
|
let mut pending = dashboard["panels"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("dashboard panels")?
|
||||||
|
.iter()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut checked = 0;
|
||||||
|
while let Some(panel) = pending.pop() {
|
||||||
|
if let Some(children) = panel["panels"].as_array() {
|
||||||
|
pending.extend(children);
|
||||||
|
}
|
||||||
|
for target in panel["targets"].as_array().into_iter().flatten() {
|
||||||
|
let Some(expression) = target["expr"].as_str() else { continue };
|
||||||
|
if !expression.contains("rustfs:storage:current") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if expression.contains("collection_scope=\"cluster\"") {
|
||||||
|
assert!(
|
||||||
|
expression.contains("observer=\"$storage_observer\""),
|
||||||
|
"global views must select one observer: {expression}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut expression = expression.to_string();
|
||||||
|
for (name, value) in [
|
||||||
|
("$__rate_interval", "5m"),
|
||||||
|
("$storage_cluster", "metrics-e2e"),
|
||||||
|
("$storage_observer", observer),
|
||||||
|
("$drive_api", ".*"),
|
||||||
|
("$server", ".*"),
|
||||||
|
("$drive", ".*"),
|
||||||
|
("$job", "rustfs"),
|
||||||
|
] {
|
||||||
|
expression = expression.replace(name, value);
|
||||||
|
}
|
||||||
|
query(client, base, &expression).await?;
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(checked > 0, "the storage dashboard queries must be exercised");
|
||||||
|
println!("PASS: {checked} storage dashboard queries against the live pipeline");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "external tools: pinned Collector, Prometheus, previous release and current RustFS binaries"]
|
||||||
|
async fn storage_metric_ownership_pipeline() -> TestResult {
|
||||||
|
let baseline = required_binary("RUSTFS_METRICS_BASELINE_BINARY")?;
|
||||||
|
let current = required_binary("CARGO_BIN_EXE_rustfs")?;
|
||||||
|
let collector = required_binary("RUSTFS_OTELCOL_BINARY")?;
|
||||||
|
let prometheus = required_binary("RUSTFS_PROMETHEUS_BINARY")?;
|
||||||
|
let temp = tempfile::Builder::new().prefix("rustfs-storage-metrics-").tempdir()?;
|
||||||
|
let work = if let Ok(path) = std::env::var("RUSTFS_METRICS_E2E_ARTIFACTS") {
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
fs::create_dir_all(&path)?;
|
||||||
|
tempfile::Builder::new().prefix("storage-run-").tempdir_in(path)?.keep()
|
||||||
|
} else {
|
||||||
|
temp.path().to_path_buf()
|
||||||
|
};
|
||||||
|
println!("Metrics pipeline logs: {}", work.display());
|
||||||
|
let otlp = free_port()?;
|
||||||
|
let scrape = free_port()?;
|
||||||
|
let prom = free_port()?;
|
||||||
|
let collector_config = work.join("collector.yaml");
|
||||||
|
fs::write(
|
||||||
|
&collector_config,
|
||||||
|
format!(
|
||||||
|
r#"receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
http:
|
||||||
|
endpoint: 127.0.0.1:{otlp}
|
||||||
|
exporters:
|
||||||
|
prometheus:
|
||||||
|
endpoint: 127.0.0.1:{scrape}
|
||||||
|
send_timestamps: true
|
||||||
|
metric_expiration: 5m
|
||||||
|
resource_to_telemetry_conversion:
|
||||||
|
enabled: true
|
||||||
|
service:
|
||||||
|
telemetry:
|
||||||
|
metrics:
|
||||||
|
level: none
|
||||||
|
logs:
|
||||||
|
level: warn
|
||||||
|
pipelines:
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [prometheus]
|
||||||
|
"#
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let rules = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../.docker/observability/prometheus-rules/rustfs-storage.yml")
|
||||||
|
.canonicalize()?;
|
||||||
|
let prom_config = work.join("prometheus.yaml");
|
||||||
|
fs::write(
|
||||||
|
&prom_config,
|
||||||
|
format!(
|
||||||
|
r#"global:
|
||||||
|
scrape_interval: 1s
|
||||||
|
evaluation_interval: 1s
|
||||||
|
rule_files:
|
||||||
|
- '{}'
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: rustfs
|
||||||
|
static_configs:
|
||||||
|
- targets: ['127.0.0.1:{scrape}']
|
||||||
|
"#,
|
||||||
|
rules.display()
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
// Use the shipped expressions, with only the test evaluation interval shortened.
|
||||||
|
let test_rules = work.join("storage-rules.yaml");
|
||||||
|
fs::write(&test_rules, fs::read_to_string(&rules)?.replace("interval: 15s", "interval: 1s"))?;
|
||||||
|
fs::write(
|
||||||
|
&prom_config,
|
||||||
|
fs::read_to_string(&prom_config)?.replace(&rules.display().to_string(), &test_rules.display().to_string()),
|
||||||
|
)?;
|
||||||
|
let _collector = start_tool(
|
||||||
|
&collector,
|
||||||
|
&[format!("--config={}", collector_config.display())],
|
||||||
|
&work.join("collector.log"),
|
||||||
|
)?;
|
||||||
|
let _prometheus = start_tool(
|
||||||
|
&prometheus,
|
||||||
|
&[
|
||||||
|
format!("--config.file={}", prom_config.display()),
|
||||||
|
format!("--web.listen-address=127.0.0.1:{prom}"),
|
||||||
|
format!("--storage.tsdb.path={}", work.join("prometheus-data").display()),
|
||||||
|
],
|
||||||
|
&work.join("prometheus.log"),
|
||||||
|
)?;
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()?;
|
||||||
|
let prom_url = format!("http://127.0.0.1:{prom}");
|
||||||
|
await_count(&http, &prom_url, "up{job=\"rustfs\"} == 1", 1).await?;
|
||||||
|
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||||
|
cluster.set_env("NO_PROXY", "127.0.0.1,localhost");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRIC_ENDPOINT", format!("http://127.0.0.1:{otlp}/v1/metrics"));
|
||||||
|
cluster.set_env("OTEL_RESOURCE_ATTRIBUTES", "rustfs.cluster.id=metrics-e2e");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METER_INTERVAL", "2");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "true");
|
||||||
|
cluster.set_env("RUSTFS_OBS_LOGS_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUSTFS_OBS_TRACES_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUSTFS_METRICS_NODE_INTERVAL", "2");
|
||||||
|
cluster.set_env("RUSTFS_METRICS_CLUSTER_INTERVAL", "5");
|
||||||
|
for index in 0..4 {
|
||||||
|
// Four localhost processes otherwise share the startup resource IP.
|
||||||
|
// Distinct test host IDs model the four hosts in a distributed deployment.
|
||||||
|
cluster.set_node_env(
|
||||||
|
index,
|
||||||
|
"OTEL_RESOURCE_ATTRIBUTES",
|
||||||
|
format!("rustfs.cluster.id=metrics-e2e,host.id=metrics-node-{index}"),
|
||||||
|
)?;
|
||||||
|
cluster.set_node_capture_log_path(index, work.join(format!("node-{index}.log")).display().to_string())?;
|
||||||
|
}
|
||||||
|
cluster.start_with_binary(&baseline).await?;
|
||||||
|
// Reproduce the original four observers x four global drives before fixing it.
|
||||||
|
await_count(&http, &prom_url, "rustfs_system_drive_total_bytes{collection_scope=\"\",drive!=\"\"}", 16).await?;
|
||||||
|
let local = "rustfs:storage:current{source_metric=\"rustfs_system_drive_total_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"metrics-e2e\"}";
|
||||||
|
for index in 0..4 {
|
||||||
|
cluster.stop_node_gracefully(index).await?;
|
||||||
|
cluster.start_node_from_binary(index, ¤t).await?;
|
||||||
|
await_count(&http, &prom_url, local, u64::try_from(index + 1)?).await?;
|
||||||
|
}
|
||||||
|
let rows = query(&http, &prom_url, local).await?;
|
||||||
|
for row in rows.as_array().ok_or("expected a metric vector")? {
|
||||||
|
assert_eq!(
|
||||||
|
row["metric"]["observer"], row["metric"]["server"],
|
||||||
|
"a node must only export its own detailed drives"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let observer = cluster.nodes[0].address.clone();
|
||||||
|
let inventory = format!(
|
||||||
|
"rustfs:storage:current{{source_metric=\"rustfs_cluster_drive_present\",collection_scope=\"cluster\",rustfs_cluster_id=\"metrics-e2e\",observer=\"{observer}\"}}"
|
||||||
|
);
|
||||||
|
await_count(&http, &prom_url, &inventory, 4).await?;
|
||||||
|
cluster.create_test_bucket("metrics-ownership").await?;
|
||||||
|
let client = cluster.create_s3_client(0)?;
|
||||||
|
for index in 0..8 {
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket("metrics-ownership")
|
||||||
|
.key(format!("object-{index}"))
|
||||||
|
.body(ByteStream::from(vec![7_u8; 4096]))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
await_count(
|
||||||
|
&http,
|
||||||
|
&prom_url,
|
||||||
|
"count by (server) (rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\"})",
|
||||||
|
4,
|
||||||
|
).await?;
|
||||||
|
let counters = query(
|
||||||
|
&http,
|
||||||
|
&prom_url,
|
||||||
|
"rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\"}",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
!counters.as_array().ok_or("expected counters")?.is_empty(),
|
||||||
|
"exercise actual storage counters"
|
||||||
|
);
|
||||||
|
for row in counters.as_array().ok_or("expected counters")? {
|
||||||
|
assert_eq!(row["metric"]["observer"], row["metric"]["server"]);
|
||||||
|
assert!(
|
||||||
|
!row["metric"]["disk_id"].as_str().unwrap_or_default().is_empty(),
|
||||||
|
"counters must carry physical disk identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_dashboard_queries(&http, &prom_url, &observer).await?;
|
||||||
|
for index in (1..4).rev() {
|
||||||
|
cluster.stop_node(index)?;
|
||||||
|
// The Collector stays alive; cached samples must not keep stopped owners fresh.
|
||||||
|
await_count(&http, &prom_url, local, u64::try_from(index)?).await?;
|
||||||
|
await_count(&http, &prom_url, &inventory, 4).await?;
|
||||||
|
let unavailable = format!(
|
||||||
|
"rustfs:storage:current{{source_metric=\"rustfs_cluster_drive_runtime_state\",collection_scope=\"cluster\",rustfs_cluster_id=\"metrics-e2e\",observer=\"{observer}\",state=~\"offline|unknown|suspect\"}} == 1"
|
||||||
|
);
|
||||||
|
await_count(&http, &prom_url, &unavailable, u64::try_from(4 - index)?).await?;
|
||||||
|
}
|
||||||
|
cluster.stop();
|
||||||
|
await_count(&http, &prom_url, local, 0).await?;
|
||||||
|
cluster.start_with_binary(¤t).await?;
|
||||||
|
await_count(&http, &prom_url, local, 4).await?;
|
||||||
|
await_count(&http, &prom_url, &inventory, 4).await?;
|
||||||
|
let restored = client.get_object().bucket("metrics-ownership").key("object-0").send().await?;
|
||||||
|
assert_eq!(restored.body.collect().await?.into_bytes().as_ref(), vec![7_u8; 4096].as_slice());
|
||||||
|
println!("PASS: baseline duplication; four rolling upgrades; owner identity; counters; 4 -> 3 -> 2 -> 1 -> 0 -> 4 recovery");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -32,8 +32,10 @@ use aws_sdk_s3::types::{
|
|||||||
VersioningConfiguration,
|
VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use http::{Method, StatusCode};
|
use http::{Method, StatusCode};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio::time::{Instant, sleep};
|
use tokio::time::{Instant, sleep};
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
|||||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|
||||||
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
|
||||||
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
||||||
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||||
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||||
@@ -81,6 +84,14 @@ const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
|
|||||||
// of treating it as an upgrade failure.
|
// of treating it as an upgrade failure.
|
||||||
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
|
struct G09EvidenceContext {
|
||||||
|
directory: PathBuf,
|
||||||
|
current_revision: String,
|
||||||
|
previous_revision: String,
|
||||||
|
run_id: String,
|
||||||
|
measurement_window_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -91,6 +102,155 @@ fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_lower_hex_revision(value: &str) -> bool {
|
||||||
|
value.len() == 40
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_source_revision() -> Result<String, BoxError> {
|
||||||
|
let revision = env!("RUSTFS_E2E_BUILD_COMMIT");
|
||||||
|
if !is_lower_hex_revision(revision) {
|
||||||
|
return Err(format!("current test binary has invalid source revision {revision}").into());
|
||||||
|
}
|
||||||
|
Ok(revision.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn binary_source_revision(binary: &Path) -> Result<String, BoxError> {
|
||||||
|
let output = tokio::process::Command::new(binary).arg("--version").output().await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!("{} --version failed with {}", binary.display(), output.status).into());
|
||||||
|
}
|
||||||
|
let version = String::from_utf8(output.stdout)?;
|
||||||
|
source_revision_from_version_output(&version, binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_revision_from_version_output(version: &str, binary: &Path) -> Result<String, BoxError> {
|
||||||
|
version
|
||||||
|
.split(|ch: char| !ch.is_ascii_hexdigit())
|
||||||
|
.find(|token| is_lower_hex_revision(token))
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| format!("{} --version did not expose a 40-byte source revision", binary.display()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn g09_evidence_context(previous_binary: &Path) -> Result<Option<G09EvidenceContext>, BoxError> {
|
||||||
|
let Some(directory) = std::env::var_os(G09_EVIDENCE_DIR_ENV) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let directory = PathBuf::from(directory);
|
||||||
|
std::fs::create_dir_all(&directory)?;
|
||||||
|
let current_revision = current_source_revision()?;
|
||||||
|
let previous_revision = binary_source_revision(previous_binary).await?;
|
||||||
|
if current_revision == previous_revision {
|
||||||
|
return Err("G09 mixed-version evidence requires distinct current and previous source revisions".into());
|
||||||
|
}
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||||
|
Ok(Some(G09EvidenceContext {
|
||||||
|
directory,
|
||||||
|
current_revision,
|
||||||
|
previous_revision,
|
||||||
|
run_id: format!("g09-upgrade-{}-{now}", std::process::id()),
|
||||||
|
measurement_window_id: format!("g09-mixed-version-window-{now}"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_g09_evidence(
|
||||||
|
context: &G09EvidenceContext,
|
||||||
|
field: &str,
|
||||||
|
role: &str,
|
||||||
|
cases: &[&str],
|
||||||
|
test: &str,
|
||||||
|
details: Value,
|
||||||
|
) -> TestResult {
|
||||||
|
let path = context.directory.join(format!("G09-{field}.json"));
|
||||||
|
let mut evidence = serde_json::json!({
|
||||||
|
"schema": 1,
|
||||||
|
"evidence_type": "measured",
|
||||||
|
"artifact_kind": "upgrade-compatibility-e2e",
|
||||||
|
"source_revision": context.current_revision,
|
||||||
|
"run_id": context.run_id,
|
||||||
|
"measurement_window_id": context.measurement_window_id,
|
||||||
|
"gate": "G09",
|
||||||
|
"field": field,
|
||||||
|
"versions": [context.previous_revision, context.current_revision],
|
||||||
|
"current_revision": context.current_revision,
|
||||||
|
"previous_revision": context.previous_revision,
|
||||||
|
"mixed_version_role": role,
|
||||||
|
"mixed_version_cases": cases,
|
||||||
|
"test": test,
|
||||||
|
"details": details,
|
||||||
|
});
|
||||||
|
if field == "rollback_payload_evidence" {
|
||||||
|
evidence["rollback_payload_replayed"] = Value::Bool(true);
|
||||||
|
}
|
||||||
|
let data = serde_json::to_vec_pretty(&evidence)?;
|
||||||
|
if data.len() > 1024 * 1024 {
|
||||||
|
return Err("G09 mixed-version evidence exceeds the 1 MiB artifact budget".into());
|
||||||
|
}
|
||||||
|
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||||
|
output.write_all(&data)?;
|
||||||
|
output.write_all(b"\n")?;
|
||||||
|
output.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod g09_evidence_tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_revision_parser_requires_lowercase_forty_byte_sha() -> TestResult {
|
||||||
|
let binary = Path::new("rustfs");
|
||||||
|
assert_eq!(
|
||||||
|
source_revision_from_version_output("rustfs 1.0.0 abcdef0123456789abcdef0123456789abcdef01 clean", binary)?,
|
||||||
|
"abcdef0123456789abcdef0123456789abcdef01"
|
||||||
|
);
|
||||||
|
assert!(source_revision_from_version_output("rustfs ABCDEF0123456789ABCDEF0123456789ABCDEF01", binary).is_err());
|
||||||
|
assert!(source_revision_from_version_output("rustfs abcdef", binary).is_err());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn g09_evidence_writer_records_measured_role_and_refuses_overwrite() -> TestResult {
|
||||||
|
let directory = TempDir::new()?;
|
||||||
|
let context = G09EvidenceContext {
|
||||||
|
directory: directory.path().to_path_buf(),
|
||||||
|
current_revision: "b".repeat(40),
|
||||||
|
previous_revision: "a".repeat(40),
|
||||||
|
run_id: "g09-upgrade-test-run".to_string(),
|
||||||
|
measurement_window_id: "g09-upgrade-test-window".to_string(),
|
||||||
|
};
|
||||||
|
write_g09_evidence(
|
||||||
|
&context,
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed-version-reader",
|
||||||
|
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||||
|
"unit",
|
||||||
|
serde_json::json!({"assertions": ["reader evidence"]}),
|
||||||
|
)?;
|
||||||
|
let path = directory.path().join("G09-mixed_version_reader_evidence.json");
|
||||||
|
let evidence: Value = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||||
|
assert_eq!(evidence["evidence_type"], "measured");
|
||||||
|
assert_eq!(evidence["gate"], "G09");
|
||||||
|
assert_eq!(evidence["field"], "mixed_version_reader_evidence");
|
||||||
|
assert_eq!(evidence["mixed_version_role"], "mixed-version-reader");
|
||||||
|
assert_eq!(evidence["versions"], serde_json::json!(["a".repeat(40), "b".repeat(40)]));
|
||||||
|
|
||||||
|
let overwrite = write_g09_evidence(
|
||||||
|
&context,
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed-version-reader",
|
||||||
|
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||||
|
"unit",
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
assert!(overwrite.is_err(), "G09 evidence must not overwrite an existing artifact");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
|
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
|
||||||
let configuration = VersioningConfiguration::builder()
|
let configuration = VersioningConfiguration::builder()
|
||||||
.status(BucketVersioningStatus::Enabled)
|
.status(BucketVersioningStatus::Enabled)
|
||||||
@@ -523,6 +683,7 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
|||||||
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
|
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let previous_binary = source_binary()?;
|
let previous_binary = source_binary()?;
|
||||||
|
let evidence_context = g09_evidence_context(&previous_binary).await?;
|
||||||
let current_binary = rustfs_binary_path();
|
let current_binary = rustfs_binary_path();
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
|
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
|
||||||
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
|
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
|
||||||
@@ -555,6 +716,45 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(context) = evidence_context.as_ref() {
|
||||||
|
let phases = ["one-current-node", "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_rc2_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"
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
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_rc2_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 under one-current-node and one-previous-node layouts"
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,6 +1336,7 @@ async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -
|
|||||||
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let previous_binary = source_binary()?;
|
let previous_binary = source_binary()?;
|
||||||
|
let evidence_context = g09_evidence_context(&previous_binary).await?;
|
||||||
|
|
||||||
let replication_target = FakeS3Target::start().await?;
|
let replication_target = FakeS3Target::start().await?;
|
||||||
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
||||||
@@ -1204,6 +1405,54 @@ async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestRes
|
|||||||
);
|
);
|
||||||
assert_eq!(body, post_rollback_bytes);
|
assert_eq!(body, post_rollback_bytes);
|
||||||
|
|
||||||
|
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||||
|
let current_again = env.create_s3_client();
|
||||||
|
assert_versioning_enabled(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||||
|
assert_default_sse_s3_encryption(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||||
|
assert_bucket_tag(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||||
|
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after rolling forward again").await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_again, ROLLBACK_BUCKET, single_key, Some(&single_version))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
single_bytes
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_again, ROLLBACK_BUCKET, multipart_key, None).await?.1,
|
||||||
|
multipart_bytes
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_again, ROLLBACK_BUCKET, post_rollback_key, None).await?.1,
|
||||||
|
post_rollback_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(context) = evidence_context.as_ref() {
|
||||||
|
write_g09_evidence(
|
||||||
|
context,
|
||||||
|
"rollback_payload_evidence",
|
||||||
|
"rollback-payload",
|
||||||
|
&["rollback-to-old", "rollback-to-new", "unknown-field-retained"],
|
||||||
|
"upgrade_compatibility_test::rollback_to_previous_release_reads_current_bucket_metadata",
|
||||||
|
serde_json::json!({
|
||||||
|
"bucket": ROLLBACK_BUCKET,
|
||||||
|
"cases": {
|
||||||
|
"rollback-to-old": [
|
||||||
|
"previous-release binary reads current-build versioning, SSE-S3, tags, replication target and objects",
|
||||||
|
"previous-release writer honors the decoded current-build encryption configuration"
|
||||||
|
],
|
||||||
|
"rollback-to-new": [
|
||||||
|
"current build reads the object written by the rolled-back previous release",
|
||||||
|
"current build reads the current-build single-part and multipart objects after rolling forward again"
|
||||||
|
],
|
||||||
|
"unknown-field-retained": [
|
||||||
|
"previous release skips current-build bucket metadata extension fields without dropping known bucket configuration",
|
||||||
|
"current build reads the retained bucket configuration after the previous-release round trip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
replication_target.shutdown().await;
|
replication_target.shutdown().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ pub mod bucket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod lifecycle {
|
pub mod lifecycle {
|
||||||
|
pub mod legacy_transition_state_reconcile {
|
||||||
|
pub use crate::bucket::lifecycle::legacy_transition_state_reconcile::{
|
||||||
|
LegacyTransitionStateCopyRepresentation, LegacyTransitionStateMetadataAlias, LegacyTransitionStateReconcileError,
|
||||||
|
LegacyTransitionStateReconcileOutcome, LegacyTransitionStateReconcileReadiness,
|
||||||
|
LegacyTransitionStateReconcileRequest, LegacyTransitionStateReconcileResponse,
|
||||||
|
LegacyTransitionStateReconcileSelector, LegacyTransitionStateSetRepresentation, LegacyTransitionStateSource,
|
||||||
|
LegacyTransitionStateTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub mod bucket_lifecycle_audit {
|
pub mod bucket_lifecycle_audit {
|
||||||
pub use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
|
pub use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
|
||||||
}
|
}
|
||||||
@@ -260,18 +270,19 @@ pub mod bucket {
|
|||||||
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
||||||
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
||||||
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
||||||
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, TargetReplicationResyncStatus,
|
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, ScannerDirtyUsageMutationObserver,
|
||||||
VersionPurgeStatusType, XferStats, assign_site_replication_rule_priorities, commit_force_delete_intent,
|
ScannerDirtyUsageMutationSource, TargetReplicationResyncStatus, VersionPurgeStatusType, XferStats,
|
||||||
complete_force_delete_intent, delete_replication_state_from_config, delete_replication_version_id,
|
assign_site_replication_rule_priorities, commit_force_delete_intent, complete_force_delete_intent,
|
||||||
get_global_replication_pool, get_global_replication_stats, get_proxy_targets, init_background_replication,
|
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
||||||
|
get_global_replication_stats, get_proxy_targets, init_background_replication,
|
||||||
invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule,
|
invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule,
|
||||||
merge_incoming_replication_config, merge_user_replication_config, persist_force_delete_intent,
|
merge_incoming_replication_config, merge_user_replication_config, persist_force_delete_intent,
|
||||||
read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
||||||
replication_target_arn_deployment_id, replication_target_arns, resync_start_conflict_id,
|
replication_target_arn_deployment_id, replication_target_arns, resync_start_conflict_id,
|
||||||
should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
set_scanner_dirty_usage_mutation_observer, should_remove_replication_target, should_schedule_delete_replication,
|
||||||
should_use_existing_delete_replication_source, site_replication_rule_deployment_id,
|
should_use_existing_delete_replication_info, should_use_existing_delete_replication_source,
|
||||||
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns,
|
site_replication_rule_deployment_id, unsupported_replication_config_field, validate_replication_config_structure,
|
||||||
version_purge_status_to_filemeta,
|
validate_replication_config_target_arns, version_purge_status_to_filemeta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +377,14 @@ pub mod config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod data_usage {
|
pub mod data_usage {
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::data_movement::SourceCleanupDeleteBarrier;
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::data_movement::scanner_backlog::test_util::NativeScannerPauseBacklogWriteFault;
|
||||||
|
pub use crate::data_movement::scanner_backlog::{
|
||||||
|
MAX_SCANNER_PAUSE_BACKLOG_BYTES, ScannerPauseBacklogRetirementPlan, ScannerPauseBacklogRetirementPlanner,
|
||||||
|
ScannerPauseBacklogRetirementReplica, register_scanner_pause_backlog_retirement_planner,
|
||||||
|
};
|
||||||
pub use crate::data_usage::{
|
pub use crate::data_usage::{
|
||||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||||
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
||||||
@@ -563,7 +582,7 @@ pub mod store_list {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod storage {
|
pub mod storage {
|
||||||
pub use crate::core::pools::HealLifecycleExpiryContext;
|
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
|
||||||
pub use crate::store::HealWalkVersion;
|
pub use crate::store::HealWalkVersion;
|
||||||
pub use crate::store::{
|
pub use crate::store::{
|
||||||
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
|
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
|
||||||
|
|||||||
@@ -966,6 +966,11 @@ async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel:
|
|||||||
if let Some(err) = first_error {
|
if let Some(err) = first_error {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
runtime_sources::notify_scanner_dirty_usage_mutation(
|
||||||
|
&oi.bucket,
|
||||||
|
&oi.name,
|
||||||
|
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
|
||||||
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4036,6 +4041,10 @@ impl ManualTransitionRunReport {
|
|||||||
|| self.skipped_queue_timeout > 0
|
|| self.skipped_queue_timeout > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_enqueue_backpressure(&self) -> bool {
|
||||||
|
self.skipped_queue_full > 0 || self.skipped_queue_closed > 0 || self.skipped_queue_timeout > 0
|
||||||
|
}
|
||||||
|
|
||||||
pub fn was_truncated(&self) -> bool {
|
pub fn was_truncated(&self) -> bool {
|
||||||
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
||||||
}
|
}
|
||||||
@@ -4251,7 +4260,7 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
|||||||
}
|
}
|
||||||
report.scanned = report.scanned.saturating_add(1);
|
report.scanned = report.scanned.saturating_add(1);
|
||||||
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
||||||
if report.has_partial_enqueue() {
|
if report.has_enqueue_backpressure() {
|
||||||
report.next_marker.clone_from(&previous_marker);
|
report.next_marker.clone_from(&previous_marker);
|
||||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
report.continuation_token =
|
report.continuation_token =
|
||||||
@@ -4745,6 +4754,11 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
|||||||
// Drop any cached restored-copy body so it does not sit resident
|
// Drop any cached restored-copy body so it does not sit resident
|
||||||
// until TTL after the copy is expired (ODC-26).
|
// until TTL after the copy is expired (ODC-26).
|
||||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||||
|
runtime_sources::notify_scanner_dirty_usage_mutation(
|
||||||
|
&oi.bucket,
|
||||||
|
&oi.name,
|
||||||
|
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
|
||||||
|
);
|
||||||
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
|
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
|
||||||
Ok(dobj)
|
Ok(dobj)
|
||||||
}
|
}
|
||||||
@@ -4780,6 +4794,11 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
|||||||
// The transitioned version is gone; evict any cached body for this object
|
// The transitioned version is gone; evict any cached body for this object
|
||||||
// so it does not linger until TTL (ODC-26).
|
// so it does not linger until TTL (ODC-26).
|
||||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||||
|
runtime_sources::notify_scanner_dirty_usage_mutation(
|
||||||
|
&oi.bucket,
|
||||||
|
&oi.name,
|
||||||
|
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
|
||||||
|
);
|
||||||
|
|
||||||
//audit_log_lifecycle(oi, ILMExpiry, tags);
|
//audit_log_lifecycle(oi, ILMExpiry, tags);
|
||||||
|
|
||||||
@@ -5161,6 +5180,9 @@ pub async fn put_restore_opts(
|
|||||||
user_defined: meta,
|
user_defined: meta,
|
||||||
version_id: oi.version_id.map(|e| e.to_string()),
|
version_id: oi.version_id.map(|e| e.to_string()),
|
||||||
mod_time: oi.mod_time,
|
mod_time: oi.mod_time,
|
||||||
|
// Restore writes stored (possibly encrypted) bytes, so the writer's
|
||||||
|
// computed MD5 is not the object's public plaintext ETag.
|
||||||
|
preserve_etag: oi.etag.clone(),
|
||||||
//expires: oi.expires,
|
//expires: oi.expires,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
@@ -9950,6 +9972,18 @@ mod tests {
|
|||||||
assert_eq!(report.skipped_queue_closed, 0);
|
assert_eq!(report.skipped_queue_closed, 0);
|
||||||
assert_eq!(report.skipped_queue_timeout, 0);
|
assert_eq!(report.skipped_queue_timeout, 0);
|
||||||
assert!(report.has_partial_enqueue());
|
assert!(report.has_partial_enqueue());
|
||||||
|
assert!(report.has_enqueue_backpressure());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_in_flight_skip_does_not_stop_the_scan() {
|
||||||
|
let options = ManualTransitionRunOptions::default();
|
||||||
|
let mut report = ManualTransitionRunReport::new("bucket", &options);
|
||||||
|
|
||||||
|
report.record_enqueue_outcome(TransitionEnqueueOutcome::AlreadyInFlight);
|
||||||
|
|
||||||
|
assert!(report.has_partial_enqueue());
|
||||||
|
assert!(!report.has_enqueue_backpressure());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ mod config_boundary;
|
|||||||
pub mod core;
|
pub mod core;
|
||||||
mod durable_namespace;
|
mod durable_namespace;
|
||||||
pub mod evaluator;
|
pub mod evaluator;
|
||||||
|
pub mod legacy_transition_state_reconcile;
|
||||||
pub mod manual_transition_job;
|
pub mod manual_transition_job;
|
||||||
mod metadata_boundary;
|
mod metadata_boundary;
|
||||||
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, get_lifecycle_config};
|
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, get_lifecycle_config};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryState, TransitionState};
|
use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryState, TransitionState};
|
||||||
use crate::runtime::sources;
|
use crate::runtime::sources;
|
||||||
|
pub(crate) use crate::runtime::sources::ScannerDirtyUsageMutationSource;
|
||||||
use crate::services::tier::tier::TierConfigMgr;
|
use crate::services::tier::tier::TierConfigMgr;
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
|
|
||||||
@@ -54,3 +55,7 @@ pub(crate) fn deployment_id() -> Option<String> {
|
|||||||
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> {
|
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> {
|
||||||
sources::bucket_lifecycle_config(bucket).await
|
sources::bucket_lifecycle_config(bucket).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn notify_scanner_dirty_usage_mutation(bucket: &str, object: &str, source: ScannerDirtyUsageMutationSource) {
|
||||||
|
sources::notify_scanner_dirty_usage_mutation(bucket, object, source);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1681,9 +1681,13 @@ impl BucketMetadataSys {
|
|||||||
expected: Option<&Arc<BucketMetadata>>,
|
expected: Option<&Arc<BucketMetadata>>,
|
||||||
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !self
|
if !await_bucket_namespace_operation(
|
||||||
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check")
|
Some(namespace_guard),
|
||||||
.await?
|
bucket,
|
||||||
|
"bucket metadata heal existence check",
|
||||||
|
self.api.bucket_exists_for_heal(bucket),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
if matches!(mode, MetadataLoadMode::Refresh) {
|
if matches!(mode, MetadataLoadMode::Refresh) {
|
||||||
let _publish_guard = self
|
let _publish_guard = self
|
||||||
|
|||||||
@@ -92,3 +92,6 @@ pub use replication_target_boundary::SsecPassthroughCapability;
|
|||||||
pub use replication_target_boundary::VersionIdentityCapability;
|
pub use replication_target_boundary::VersionIdentityCapability;
|
||||||
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
|
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
|
||||||
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
||||||
|
pub use runtime_boundary::{
|
||||||
|
ScannerDirtyUsageMutationObserver, ScannerDirtyUsageMutationSource, set_scanner_dirty_usage_mutation_observer,
|
||||||
|
};
|
||||||
|
|||||||
@@ -3843,6 +3843,13 @@ async fn persist_replication_state_if_current<S: ReplicationStorage>(
|
|||||||
match storage.put_object_metadata(&roi.bucket, &roi.name, &write_opts).await {
|
match storage.put_object_metadata(&roi.bucket, &roi.name, &write_opts).await {
|
||||||
Ok(updated) => {
|
Ok(updated) => {
|
||||||
*object_info = updated;
|
*object_info = updated;
|
||||||
|
if mode == ReplicationStatusWritebackMode::Update {
|
||||||
|
runtime_sources::notify_scanner_dirty_usage_mutation(
|
||||||
|
&roi.bucket,
|
||||||
|
&roi.name,
|
||||||
|
runtime_sources::ScannerDirtyUsageMutationSource::Replication,
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(ReplicationStatePersistOutcome::Updated)
|
Ok(ReplicationStatePersistOutcome::Updated)
|
||||||
}
|
}
|
||||||
Err(Error::PreconditionFailed) => Ok(ReplicationStatePersistOutcome::Superseded),
|
Err(Error::PreconditionFailed) => Ok(ReplicationStatePersistOutcome::Superseded),
|
||||||
@@ -8220,7 +8227,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
mod multipart_transport_tests {
|
mod multipart_transport_tests {
|
||||||
use super::super::super::replication_filemeta_boundary::ObjectPartInfo;
|
use super::super::super::replication_filemeta_boundary::ObjectPartInfo;
|
||||||
use super::super::super::replication_storage_boundary::{ObjectIO as _, ReadPlan};
|
use super::super::super::replication_storage_boundary::ObjectIO as _;
|
||||||
use super::*;
|
use super::*;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http_body_util::{BodyExt, Full};
|
use http_body_util::{BodyExt, Full};
|
||||||
@@ -8229,7 +8236,6 @@ mod tests {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Source {
|
struct Source {
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
stored: Option<Bytes>,
|
|
||||||
info: ObjectInfo,
|
info: ObjectInfo,
|
||||||
ranges: StdMutex<Vec<(i64, i64)>>,
|
ranges: StdMutex<Vec<(i64, i64)>>,
|
||||||
full_reads: std::sync::atomic::AtomicUsize,
|
full_reads: std::sync::atomic::AtomicUsize,
|
||||||
@@ -8258,17 +8264,6 @@ mod tests {
|
|||||||
self.info.version_id.map(|id| id.to_string()),
|
self.info.version_id.map(|id| id.to_string()),
|
||||||
"every read retains the selected source version"
|
"every read retains the selected source version"
|
||||||
);
|
);
|
||||||
if let Some(stored) = &self.stored {
|
|
||||||
if let Some(range) = &range {
|
|
||||||
self.ranges.lock().expect("range journal lock").push((range.start, range.end));
|
|
||||||
} else {
|
|
||||||
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
let plan = ReadPlan::build_for_request(range, &self.info, opts, &HeaderMap::new(), None).await?;
|
|
||||||
let start = plan.storage_offset();
|
|
||||||
let end = start + usize::try_from(plan.storage_length()).expect("nonnegative storage length");
|
|
||||||
return plan.into_object_reader(Box::new(std::io::Cursor::new(stored.slice(start..end))), &self.info);
|
|
||||||
}
|
|
||||||
if range.is_none() {
|
if range.is_none() {
|
||||||
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
self.full_reads.fetch_add(1, Ordering::Relaxed);
|
||||||
return Ok(GetObjectReader {
|
return Ok(GetObjectReader {
|
||||||
@@ -8427,7 +8422,6 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
stored: Some(Bytes::from(stored)),
|
|
||||||
ranges: StdMutex::new(Vec::new()),
|
ranges: StdMutex::new(Vec::new()),
|
||||||
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
@@ -8669,7 +8663,6 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
stored: None,
|
|
||||||
ranges: StdMutex::new(Vec::new()),
|
ranges: StdMutex::new(Vec::new()),
|
||||||
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
full_reads: std::sync::atomic::AtomicUsize::new(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub(crate) use crate::object_api::{
|
|||||||
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode,
|
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence, ReadPlan};
|
pub(crate) use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence};
|
||||||
pub(crate) use crate::storage_api_contracts::list::{
|
pub(crate) use crate::storage_api_contracts::list::{
|
||||||
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
|
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ use super::replication_pool::DynReplicationPool;
|
|||||||
use super::replication_state::ReplicationStats;
|
use super::replication_state::ReplicationStats;
|
||||||
use super::replication_storage_boundary::ReplicationObjectStore;
|
use super::replication_storage_boundary::ReplicationObjectStore;
|
||||||
use crate::runtime::sources;
|
use crate::runtime::sources;
|
||||||
|
pub use crate::runtime::sources::{
|
||||||
|
ScannerDirtyUsageMutationObserver, ScannerDirtyUsageMutationSource, set_scanner_dirty_usage_mutation_observer,
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) fn object_store_handle() -> Option<Arc<ReplicationObjectStore>> {
|
pub(crate) fn object_store_handle() -> Option<Arc<ReplicationObjectStore>> {
|
||||||
sources::object_store_handle()
|
sources::object_store_handle()
|
||||||
@@ -43,3 +46,7 @@ pub(crate) fn replication_runtime_initialized() -> bool {
|
|||||||
pub(crate) fn bucket_monitor() -> Option<Arc<ReplicationBucketMonitor>> {
|
pub(crate) fn bucket_monitor() -> Option<Arc<ReplicationBucketMonitor>> {
|
||||||
sources::bucket_monitor()
|
sources::bucket_monitor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn notify_scanner_dirty_usage_mutation(bucket: &str, object: &str, source: ScannerDirtyUsageMutationSource) {
|
||||||
|
sources::notify_scanner_dirty_usage_mutation(bucket, object, source);
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,6 +285,20 @@ fn peer_replay_state(audience: &str) -> PeerReplayState {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_peer_replay_state_for_addr(addr: &str) -> std::io::Result<()> {
|
||||||
|
let uri = addr
|
||||||
|
.parse::<Uri>()
|
||||||
|
.map_err(|_| std::io::Error::other("Invalid gRPC peer URI"))?;
|
||||||
|
let audience = uri
|
||||||
|
.authority()
|
||||||
|
.map(|authority| normalize_tonic_rpc_audience(authority.as_str()))
|
||||||
|
.ok_or_else(|| std::io::Error::other("Missing gRPC peer authority"))??;
|
||||||
|
if let Ok(mut states) = PEER_REPLAY_STATES.lock() {
|
||||||
|
states.remove(&audience);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_peer_replay_response(
|
fn apply_peer_replay_response(
|
||||||
audience: String,
|
audience: String,
|
||||||
sent_state: PeerReplayState,
|
sent_state: PeerReplayState,
|
||||||
@@ -619,6 +633,13 @@ mod tests {
|
|||||||
.remove(audience);
|
.remove(audience);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_peer_capability(audience: &str, state: PeerReplayState) {
|
||||||
|
PEER_REPLAY_STATES
|
||||||
|
.lock()
|
||||||
|
.expect("peer capability cache lock must not be poisoned")
|
||||||
|
.insert(audience.to_string(), state);
|
||||||
|
}
|
||||||
|
|
||||||
fn rolling_mutation_request(method: &'static str) -> tonic::Request<()> {
|
fn rolling_mutation_request(method: &'static str) -> tonic::Request<()> {
|
||||||
let mut request = tonic::Request::new(rustfs_protos::proto_gen::node_service::GenerallyLockRequest {
|
let mut request = tonic::Request::new(rustfs_protos::proto_gen::node_service::GenerallyLockRequest {
|
||||||
args: "canonical mutation request".to_string(),
|
args: "canonical mutation request".to_string(),
|
||||||
@@ -1090,6 +1111,23 @@ mod tests {
|
|||||||
clear_peer_capability(audience);
|
clear_peer_capability(audience);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_peer_replay_state_for_addr_removes_normalized_audience() {
|
||||||
|
let audience = "clear-peer-replay-state-test:9000";
|
||||||
|
let boot_epoch = Uuid::new_v4();
|
||||||
|
set_peer_capability(
|
||||||
|
audience,
|
||||||
|
PeerReplayState {
|
||||||
|
boot_epoch: Some(boot_epoch),
|
||||||
|
cache_capability: Some(PeerReplayCapability::Capable { boot_epoch }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
clear_peer_replay_state_for_addr("http://clear-peer-replay-state-test:9000").expect("peer URI should clear replay state");
|
||||||
|
|
||||||
|
assert_eq!(peer_replay_state(audience), PeerReplayState::default());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interceptor_snapshot_prevents_delayed_legacy_response_from_revoking_capability() {
|
fn interceptor_snapshot_prevents_delayed_legacy_response_from_revoking_capability() {
|
||||||
ensure_test_rpc_secret();
|
ensure_test_rpc_secret();
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::cluster::rpc::client::{
|
use crate::cluster::rpc::client::{
|
||||||
AuthenticatedChannel, TonicInterceptor, embedded_tonic_status, gen_tonic_signature_interceptor, heal_control_time_out_client,
|
AuthenticatedChannel, TonicInterceptor, clear_peer_replay_state_for_addr, embedded_tonic_status,
|
||||||
is_network_like_status, message_has_network_needle, node_service_time_out_client, tier_mutation_control_time_out_client,
|
gen_tonic_signature_interceptor, heal_control_time_out_client, is_network_like_status, message_has_network_needle,
|
||||||
|
node_service_time_out_client, tier_mutation_control_time_out_client,
|
||||||
};
|
};
|
||||||
use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, verify_tonic_rpc_response_proof};
|
use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, verify_tonic_rpc_response_proof};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
@@ -50,10 +51,10 @@ use rustfs_protos::proto_gen::node_service::{
|
|||||||
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
|
LocalStorageInfoRequest, Mss, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest,
|
||||||
ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse,
|
ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse,
|
||||||
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse,
|
ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse,
|
||||||
ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageEntry, ServerInfoRequest, SignalServiceRequest,
|
ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageAckResponse, ScannerScopedDirtyUsageEntry, ServerInfoRequest,
|
||||||
SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest,
|
SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest,
|
||||||
TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse, TierMutationFailureClass,
|
TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse,
|
||||||
TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
|
||||||
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||||
};
|
};
|
||||||
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
|
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
|
||||||
@@ -288,6 +289,20 @@ fn scanner_scoped_dirty_usage_ack_payload(
|
|||||||
Ok(payload)
|
Ok(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scanner_scoped_dirty_usage_ack_response_matches(
|
||||||
|
request: &ScannerScopedDirtyUsageAckRequest,
|
||||||
|
response: &ScannerScopedDirtyUsageAckResponse,
|
||||||
|
) -> bool {
|
||||||
|
let cleared_within_request = u64::try_from(request.entries.len())
|
||||||
|
.is_ok_and(|entry_count| response.cleared <= entry_count && (!request.probe_only || response.cleared == 0));
|
||||||
|
response.protocol_version == rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_PROTOCOL_VERSION
|
||||||
|
&& response.owner_id == request.owner_id
|
||||||
|
&& response.instance_id == request.instance_id
|
||||||
|
&& response.max_entries == rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_ENTRIES
|
||||||
|
&& response.max_request_bytes == rustfs_protos::scoped_dirty_usage::SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES
|
||||||
|
&& cleared_within_request
|
||||||
|
}
|
||||||
|
|
||||||
fn scanner_scoped_dirty_usage_ack_reconciled(activity: &ScannerPeerActivity, expected_instance_id: &str) -> bool {
|
fn scanner_scoped_dirty_usage_ack_reconciled(activity: &ScannerPeerActivity, expected_instance_id: &str) -> bool {
|
||||||
activity.instance_id == expected_instance_id && activity.dirty_usage_pending == Some(false)
|
activity.instance_id == expected_instance_id && activity.dirty_usage_pending == Some(false)
|
||||||
}
|
}
|
||||||
@@ -544,6 +559,16 @@ fn validate_heal_control_response_proof(canonical_response: &[u8], proof: &[u8])
|
|||||||
.map_err(|_| Error::other("peer returned an invalid heal control response proof"))
|
.map_err(|_| Error::other("peer returned an invalid heal control response proof"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn heal_control_auth_may_need_replay_scope_refresh(err: &Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
Error::Io(io_err)
|
||||||
|
if embedded_tonic_status(io_err).is_some_and(|status| {
|
||||||
|
status.code() == tonic::Code::Unauthenticated && status.message() == "No valid auth token"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn decode_remote_version_state_capability(expected_member: &str, result: &[u8]) -> Result<Uuid> {
|
fn decode_remote_version_state_capability(expected_member: &str, result: &[u8]) -> Result<Uuid> {
|
||||||
let (topology_member, process_epoch) = rustfs_protos::decode_remote_version_state_capability(result).map_err(Error::other)?;
|
let (topology_member, process_epoch) = rustfs_protos::decode_remote_version_state_capability(result).map_err(Error::other)?;
|
||||||
if topology_member != expected_member {
|
if topology_member != expected_member {
|
||||||
@@ -1720,45 +1745,72 @@ impl PeerRestClient {
|
|||||||
return Err(Error::other("heal control command exceeds size limit"));
|
return Err(Error::other("heal control command exceeds size limit"));
|
||||||
}
|
}
|
||||||
let capability_probe = rustfs_protos::is_heal_control_capability_probe(&command);
|
let capability_probe = rustfs_protos::is_heal_control_capability_probe(&command);
|
||||||
self.finalize_result(
|
let result = self
|
||||||
async {
|
.heal_control_once(version, &topology_fingerprint, &command, capability_probe)
|
||||||
let mut client = self
|
.await;
|
||||||
.get_heal_control_client()
|
if result
|
||||||
.await?
|
.as_ref()
|
||||||
.max_encoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE)
|
.err()
|
||||||
.max_decoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE);
|
.is_some_and(heal_control_auth_may_need_replay_scope_refresh)
|
||||||
let canonical_body = rustfs_protos::canonical_heal_control_request_body(version, &topology_fingerprint, &command)
|
{
|
||||||
.map_err(|_| Error::other("heal control request length cannot be represented"))?;
|
self.prepare_heal_control_auth_retry().await;
|
||||||
let mut request = Request::new(HealControlRequest {
|
return self
|
||||||
version,
|
.finalize_result(
|
||||||
topology_fingerprint: topology_fingerprint.clone(),
|
self.heal_control_once(version, &topology_fingerprint, &command, capability_probe)
|
||||||
command: command.clone().into(),
|
.await,
|
||||||
});
|
)
|
||||||
request.set_timeout(rustfs_protos::heal_control_execution_timeout());
|
.await;
|
||||||
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
|
}
|
||||||
let response = client.heal_control(request).await?.into_inner();
|
self.finalize_result(result).await
|
||||||
if !response.success {
|
}
|
||||||
return Err(Error::other(
|
|
||||||
response
|
async fn prepare_heal_control_auth_retry(&self) {
|
||||||
.error_info
|
if let Err(err) = clear_peer_replay_state_for_addr(&self.grid_host) {
|
||||||
.unwrap_or_else(|| "peer heal control failed without an error".to_string()),
|
debug!(
|
||||||
));
|
peer = %self.grid_host,
|
||||||
}
|
error = %err,
|
||||||
if !capability_probe {
|
"could not clear heal control replay state before retry"
|
||||||
let canonical_response = rustfs_protos::canonical_heal_control_response_body(
|
);
|
||||||
version,
|
}
|
||||||
&topology_fingerprint,
|
self.evict_connection().await;
|
||||||
&command,
|
}
|
||||||
&response.result,
|
|
||||||
)
|
async fn heal_control_once(
|
||||||
|
&self,
|
||||||
|
version: u32,
|
||||||
|
topology_fingerprint: &str,
|
||||||
|
command: &[u8],
|
||||||
|
capability_probe: bool,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let mut client = self
|
||||||
|
.get_heal_control_client()
|
||||||
|
.await?
|
||||||
|
.max_encoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE)
|
||||||
|
.max_decoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE);
|
||||||
|
let canonical_body = rustfs_protos::canonical_heal_control_request_body(version, topology_fingerprint, command)
|
||||||
|
.map_err(|_| Error::other("heal control request length cannot be represented"))?;
|
||||||
|
let mut request = Request::new(HealControlRequest {
|
||||||
|
version,
|
||||||
|
topology_fingerprint: topology_fingerprint.to_string(),
|
||||||
|
command: command.to_vec().into(),
|
||||||
|
});
|
||||||
|
request.set_timeout(rustfs_protos::heal_control_execution_timeout());
|
||||||
|
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
|
||||||
|
let response = client.heal_control(request).await?.into_inner();
|
||||||
|
if !response.success {
|
||||||
|
return Err(Error::other(
|
||||||
|
response
|
||||||
|
.error_info
|
||||||
|
.unwrap_or_else(|| "peer heal control failed without an error".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !capability_probe {
|
||||||
|
let canonical_response =
|
||||||
|
rustfs_protos::canonical_heal_control_response_body(version, topology_fingerprint, command, &response.result)
|
||||||
.map_err(|_| Error::other("heal control response length cannot be represented"))?;
|
.map_err(|_| Error::other("heal control response length cannot be represented"))?;
|
||||||
validate_heal_control_response_proof(&canonical_response, &response.response_proof)?;
|
validate_heal_control_response_proof(&canonical_response, &response.response_proof)?;
|
||||||
}
|
}
|
||||||
Ok(response.result.to_vec())
|
Ok(response.result.to_vec())
|
||||||
}
|
|
||||||
.await,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirms that a peer supports the current heal-control coordination
|
/// Confirms that a peer supports the current heal-control coordination
|
||||||
@@ -2217,13 +2269,7 @@ impl PeerRestClient {
|
|||||||
let body = canonical_scoped_dirty_usage_response(&canonical, &response)
|
let body = canonical_scoped_dirty_usage_response(&canonical, &response)
|
||||||
.map_err(|_| Error::other("scoped dirty usage capability response is too large"))?;
|
.map_err(|_| Error::other("scoped dirty usage capability response is too large"))?;
|
||||||
verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?;
|
verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?;
|
||||||
if response.protocol_version != SCOPED_DIRTY_USAGE_PROTOCOL_VERSION
|
if !scanner_scoped_dirty_usage_ack_response_matches(&payload, &response) {
|
||||||
|| response.owner_id != payload.owner_id
|
|
||||||
|| response.instance_id != payload.instance_id
|
|
||||||
|| response.max_entries != SCOPED_DIRTY_USAGE_MAX_ENTRIES
|
|
||||||
|| response.max_request_bytes != SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES
|
|
||||||
|| response.cleared != 0
|
|
||||||
{
|
|
||||||
return Err(Error::other("scoped dirty usage capability response does not match request"));
|
return Err(Error::other("scoped dirty usage capability response does not match request"));
|
||||||
}
|
}
|
||||||
if !response.supported {
|
if !response.supported {
|
||||||
@@ -2259,13 +2305,7 @@ impl PeerRestClient {
|
|||||||
let body = canonical_scoped_dirty_usage_response(&canonical, &response)
|
let body = canonical_scoped_dirty_usage_response(&canonical, &response)
|
||||||
.map_err(|_| Error::other("scoped dirty usage acknowledgement response is too large"))?;
|
.map_err(|_| Error::other("scoped dirty usage acknowledgement response is too large"))?;
|
||||||
verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?;
|
verify_tonic_rpc_response_proof(&body, response.response_proof.as_ref())?;
|
||||||
if response.protocol_version != SCOPED_DIRTY_USAGE_PROTOCOL_VERSION
|
if !scanner_scoped_dirty_usage_ack_response_matches(&payload, &response) || !response.supported {
|
||||||
|| response.owner_id != payload.owner_id
|
|
||||||
|| response.instance_id != payload.instance_id
|
|
||||||
|| response.max_entries != SCOPED_DIRTY_USAGE_MAX_ENTRIES
|
|
||||||
|| response.max_request_bytes != SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES
|
|
||||||
|| !response.supported
|
|
||||||
{
|
|
||||||
return Err(Error::other("scoped dirty usage acknowledgement response does not match request"));
|
return Err(Error::other("scoped dirty usage acknowledgement response does not match request"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3080,6 +3120,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scanner_scoped_dirty_usage_ack_response_bounds_cleared_entries_to_request() {
|
||||||
|
use rustfs_protos::scoped_dirty_usage::{
|
||||||
|
SCOPED_DIRTY_USAGE_MAX_ENTRIES, SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES, SCOPED_DIRTY_USAGE_PROTOCOL_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request = scanner_scoped_dirty_usage_ack_payload(
|
||||||
|
"33333333-3333-3333-3333-333333333333",
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
false,
|
||||||
|
vec![
|
||||||
|
ScannerScopedDirtyUsageEntry {
|
||||||
|
bucket: "archive".to_string(),
|
||||||
|
bucket_incarnation: Uuid::from_u128(0x11111111111111111111111111111111).as_bytes().to_vec().into(),
|
||||||
|
generation: 3,
|
||||||
|
},
|
||||||
|
ScannerScopedDirtyUsageEntry {
|
||||||
|
bucket: "photos".to_string(),
|
||||||
|
bucket_incarnation: Uuid::from_u128(0x22222222222222222222222222222222).as_bytes().to_vec().into(),
|
||||||
|
generation: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("two ordered entries should form a valid scoped ACK request");
|
||||||
|
let mut response = ScannerScopedDirtyUsageAckResponse {
|
||||||
|
protocol_version: SCOPED_DIRTY_USAGE_PROTOCOL_VERSION,
|
||||||
|
owner_id: request.owner_id.clone(),
|
||||||
|
instance_id: request.instance_id.clone(),
|
||||||
|
supported: true,
|
||||||
|
max_entries: SCOPED_DIRTY_USAGE_MAX_ENTRIES,
|
||||||
|
max_request_bytes: SCOPED_DIRTY_USAGE_MAX_REQUEST_BYTES,
|
||||||
|
cleared: 1,
|
||||||
|
response_proof: Bytes::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(scanner_scoped_dirty_usage_ack_response_matches(&request, &response));
|
||||||
|
response.cleared = 2;
|
||||||
|
assert!(scanner_scoped_dirty_usage_ack_response_matches(&request, &response));
|
||||||
|
response.cleared = 3;
|
||||||
|
assert!(
|
||||||
|
!scanner_scoped_dirty_usage_ack_response_matches(&request, &response),
|
||||||
|
"a peer cannot clear more entries than the signed request contains"
|
||||||
|
);
|
||||||
|
|
||||||
|
request.probe_only = true;
|
||||||
|
response.cleared = 1;
|
||||||
|
assert!(
|
||||||
|
!scanner_scoped_dirty_usage_ack_response_matches(&request, &response),
|
||||||
|
"a capability probe cannot report a mutation"
|
||||||
|
);
|
||||||
|
response.cleared = 0;
|
||||||
|
assert!(scanner_scoped_dirty_usage_ack_response_matches(&request, &response));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scanner_scoped_dirty_usage_ack_reconciliation_requires_same_clean_instance() {
|
fn scanner_scoped_dirty_usage_ack_reconciliation_requires_same_clean_instance() {
|
||||||
let activity = |instance_id: &str, pending| ScannerPeerActivity {
|
let activity = |instance_id: &str, pending| ScannerPeerActivity {
|
||||||
@@ -3728,6 +3822,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heal_control_auth_retry_is_limited_to_transport_auth_rejection() {
|
||||||
|
assert!(heal_control_auth_may_need_replay_scope_refresh(&Error::from(
|
||||||
|
tonic::Status::unauthenticated("No valid auth token")
|
||||||
|
)));
|
||||||
|
assert!(!heal_control_auth_may_need_replay_scope_refresh(&Error::from(
|
||||||
|
tonic::Status::permission_denied("bad signature")
|
||||||
|
)));
|
||||||
|
assert!(!heal_control_auth_may_need_replay_scope_refresh(&Error::from(
|
||||||
|
tonic::Status::unauthenticated("application rejected heal control")
|
||||||
|
)));
|
||||||
|
assert!(!heal_control_auth_may_need_replay_scope_refresh(&Error::other(
|
||||||
|
"Io error: code: 'Unauthenticated', message: \"No valid auth token\""
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_rest_client_network_classifier_keeps_slow_peers_online() {
|
fn peer_rest_client_network_classifier_keeps_slow_peers_online() {
|
||||||
// The per-RPC channel deadline (RUSTFS_INTERNODE_RPC_TIMEOUT, 30s)
|
// The per-RPC channel deadline (RUSTFS_INTERNODE_RPC_TIMEOUT, 30s)
|
||||||
|
|||||||
@@ -22,21 +22,152 @@ use rustfs_lock::{
|
|||||||
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
LockClient, LockError, LockInfo, LockRequest, LockResponse, LockStats, LockStatus, LockType, Result,
|
||||||
types::{LockId, LockMetadata, LockPriority},
|
types::{LockId, LockMetadata, LockPriority},
|
||||||
};
|
};
|
||||||
use rustfs_protos::proto_gen::node_service::{BatchGenerallyLockRequest, GenerallyLockRequest, PingRequest};
|
use rustfs_protos::proto_gen::node_service::{
|
||||||
|
BatchGenerallyLockRequest, BatchGenerallyLockResponse, GenerallyLockRequest, GenerallyLockResponse, GenerallyLockResult,
|
||||||
|
PingRequest,
|
||||||
|
};
|
||||||
use rustfs_protos::{
|
use rustfs_protos::{
|
||||||
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
|
ConnectionEvictionLogLevel, evict_failed_connection_with_log_level, models::PingBodyBuilder,
|
||||||
proto_gen::node_service::node_service_client::NodeServiceClient,
|
proto_gen::node_service::node_service_client::NodeServiceClient,
|
||||||
};
|
};
|
||||||
use std::{sync::OnceLock, time::Duration};
|
use std::collections::HashMap;
|
||||||
use tokio::time::timeout;
|
use std::future::Future;
|
||||||
use tonic::Request;
|
use std::pin::Pin;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::time::{Instant, timeout};
|
||||||
use tonic::service::interceptor::InterceptedService;
|
use tonic::service::interceptor::InterceptedService;
|
||||||
|
use tonic::{Request, Response};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
fn attach_lock_mutation_body_digest<T: rustfs_protos::CanonicalMutationBody>(request: &mut Request<T>) -> std::io::Result<()> {
|
||||||
set_tonic_rolling_mutation_body_digest(request)
|
set_tonic_rolling_mutation_body_digest(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Work to run if an RPC that already timed out for its caller completes later.
|
||||||
|
type LateCompletion<T> = Option<Box<dyn FnOnce(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send>>;
|
||||||
|
|
||||||
|
/// The liveness window is this many RPC deadlines: a peer that completed a
|
||||||
|
/// lock RPC within it is slow, not gone, and keeps its channel on a timeout.
|
||||||
|
const LOCK_RPC_LIVENESS_WINDOW_DEADLINES: u32 = 2;
|
||||||
|
|
||||||
|
/// Recent history of the shared lock channel to one peer (issue #7363).
|
||||||
|
///
|
||||||
|
/// A single request deadline says nothing about the HTTP/2 connection it ran
|
||||||
|
/// on: a peer whose lock service is merely slow keeps answering other streams.
|
||||||
|
/// Evicting the cached channel on every timeout turned that slowness into a
|
||||||
|
/// `RST_STREAM`/`GOAWAY too_many_resets`/re-dial loop across the cluster, so
|
||||||
|
/// eviction now requires the peer to have gone quiet and is rate limited.
|
||||||
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
|
struct LockPeerChannelHealth {
|
||||||
|
last_success: Option<Instant>,
|
||||||
|
last_eviction: Option<Instant>,
|
||||||
|
consecutive_timeouts: u32,
|
||||||
|
/// Timed-out RPCs still running in the background for this peer.
|
||||||
|
detached_rpcs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_peer_channel_health() -> &'static Mutex<HashMap<String, LockPeerChannelHealth>> {
|
||||||
|
static HEALTH: OnceLock<Mutex<HashMap<String, LockPeerChannelHealth>>> = OnceLock::new();
|
||||||
|
HEALTH.get_or_init(Mutex::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_lock_peer_health<R>(addr: &str, update: impl FnOnce(&mut LockPeerChannelHealth) -> R) -> R {
|
||||||
|
let mut peers = lock_peer_channel_health()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
update(peers.entry(addr.to_string()).or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn lock_peer_health_for_test(addr: &str) -> LockPeerChannelHealth {
|
||||||
|
lock_peer_channel_health()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.get(addr)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn reset_lock_peer_health_for_test(addr: &str) {
|
||||||
|
lock_peer_channel_health()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.remove(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum EvictionTrigger {
|
||||||
|
/// The caller's deadline expired while the stream was still open.
|
||||||
|
Timeout,
|
||||||
|
/// The transport itself reported the failure (refused, reset, GOAWAY, ...).
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvictionTrigger {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Timeout => "timeout",
|
||||||
|
Self::Transport => "transport",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum EvictionVerdict {
|
||||||
|
Evict,
|
||||||
|
/// The peer completed a lock RPC within the liveness window: slow, not gone.
|
||||||
|
PeerRecentlyServed,
|
||||||
|
/// The channel was re-dialed within the cooldown; let it prove itself first.
|
||||||
|
CoolingDown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvictionVerdict {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Evict => "evict",
|
||||||
|
Self::PeerRecentlyServed => "peer_recently_served",
|
||||||
|
Self::CoolingDown => "cooling_down",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether a failed lock RPC may evict the shared channel to its peer.
|
||||||
|
fn eviction_verdict(
|
||||||
|
health: &LockPeerChannelHealth,
|
||||||
|
now: Instant,
|
||||||
|
trigger: EvictionTrigger,
|
||||||
|
liveness_window: Duration,
|
||||||
|
cooldown: Duration,
|
||||||
|
) -> EvictionVerdict {
|
||||||
|
if trigger == EvictionTrigger::Timeout
|
||||||
|
&& health
|
||||||
|
.last_success
|
||||||
|
.is_some_and(|at| now.saturating_duration_since(at) < liveness_window)
|
||||||
|
{
|
||||||
|
return EvictionVerdict::PeerRecentlyServed;
|
||||||
|
}
|
||||||
|
if health
|
||||||
|
.last_eviction
|
||||||
|
.is_some_and(|at| now.saturating_duration_since(at) < cooldown)
|
||||||
|
{
|
||||||
|
return EvictionVerdict::CoolingDown;
|
||||||
|
}
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock ids whose batch entry the server reports as granted.
|
||||||
|
fn acquired_lock_ids(lock_ids: &[LockId], results: &[GenerallyLockResult]) -> Vec<LockId> {
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.zip(lock_ids)
|
||||||
|
.filter(|(result, _)| result.success)
|
||||||
|
.map(|(_, lock_id)| lock_id.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Remote lock client implementation
|
/// Remote lock client implementation
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RemoteClient {
|
pub struct RemoteClient {
|
||||||
@@ -198,14 +329,202 @@ impl RemoteClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_rpc<T, F>(&self, op: &'static str, resource_summary: &str, future: F) -> std::result::Result<T, LockError>
|
fn eviction_cooldown() -> Duration {
|
||||||
|
Duration::from_millis(rustfs_utils::get_env_u64(
|
||||||
|
rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
||||||
|
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detached_rpc_limit() -> usize {
|
||||||
|
rustfs_utils::get_env_usize(
|
||||||
|
rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
||||||
|
rustfs_config::DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn liveness_window(deadline: Duration) -> Duration {
|
||||||
|
deadline.saturating_mul(LOCK_RPC_LIVENESS_WINDOW_DEADLINES)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_rpc_success(&self) {
|
||||||
|
with_lock_peer_health(&self.addr, |health| {
|
||||||
|
health.last_success = Some(Instant::now());
|
||||||
|
health.consecutive_timeouts = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the per-peer eviction policy after a failed RPC.
|
||||||
|
async fn maybe_evict_connection(
|
||||||
|
&self,
|
||||||
|
op: &'static str,
|
||||||
|
reason: &str,
|
||||||
|
resource_summary: &str,
|
||||||
|
trigger: EvictionTrigger,
|
||||||
|
deadline: Duration,
|
||||||
|
) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let cooldown = Self::eviction_cooldown();
|
||||||
|
let liveness_window = Self::liveness_window(deadline);
|
||||||
|
let (verdict, consecutive_timeouts) = with_lock_peer_health(&self.addr, |health| {
|
||||||
|
if trigger == EvictionTrigger::Timeout {
|
||||||
|
health.consecutive_timeouts = health.consecutive_timeouts.saturating_add(1);
|
||||||
|
}
|
||||||
|
let verdict = eviction_verdict(health, now, trigger, liveness_window, cooldown);
|
||||||
|
if verdict == EvictionVerdict::Evict {
|
||||||
|
health.last_eviction = Some(now);
|
||||||
|
}
|
||||||
|
(verdict, health.consecutive_timeouts)
|
||||||
|
});
|
||||||
|
if verdict == EvictionVerdict::Evict {
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction(&self.addr, trigger.as_str());
|
||||||
|
self.evict_connection(op, reason, resource_summary).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_channel_eviction_suppressed(&self.addr, verdict.as_str());
|
||||||
|
debug!(
|
||||||
|
addr = %self.addr,
|
||||||
|
op,
|
||||||
|
resource_summary,
|
||||||
|
trigger = trigger.as_str(),
|
||||||
|
verdict = verdict.as_str(),
|
||||||
|
consecutive_timeouts,
|
||||||
|
"Keeping cached remote lock connection after RPC failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep a timed-out RPC running instead of cancelling its stream.
|
||||||
|
///
|
||||||
|
/// Dropping the future sends `RST_STREAM`; under load those resets pile up
|
||||||
|
/// in the server's pending-accept queue until it answers `GOAWAY
|
||||||
|
/// too_many_resets` and kills every stream on the connection. A detached
|
||||||
|
/// stream ends on its own within the internode RPC timeout, the number per
|
||||||
|
/// peer is bounded, and a lock granted after its caller gave up is released.
|
||||||
|
fn detach_timed_out_rpc<T: Send + 'static>(
|
||||||
|
&self,
|
||||||
|
op: &'static str,
|
||||||
|
resource_summary: &str,
|
||||||
|
handle: JoinHandle<std::result::Result<T, tonic::Status>>,
|
||||||
|
late: LateCompletion<T>,
|
||||||
|
) {
|
||||||
|
let limit = Self::detached_rpc_limit();
|
||||||
|
let admitted = with_lock_peer_health(&self.addr, |health| {
|
||||||
|
if health.detached_rpcs >= limit {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
health.detached_rpcs += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !admitted {
|
||||||
|
handle.abort();
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "aborted");
|
||||||
|
debug!(
|
||||||
|
addr = %self.addr,
|
||||||
|
op,
|
||||||
|
resource_summary,
|
||||||
|
limit,
|
||||||
|
"Cancelled timed-out remote lock RPC because the detached stream budget is exhausted"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_detached(op, "detached");
|
||||||
|
let addr = self.addr.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let outcome = handle.await;
|
||||||
|
with_lock_peer_health(&addr, |health| health.detached_rpcs = health.detached_rpcs.saturating_sub(1));
|
||||||
|
match outcome {
|
||||||
|
Ok(Ok(response)) => {
|
||||||
|
with_lock_peer_health(&addr, |health| {
|
||||||
|
health.last_success = Some(Instant::now());
|
||||||
|
health.consecutive_timeouts = 0;
|
||||||
|
});
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "success");
|
||||||
|
if let Some(late) = late {
|
||||||
|
late(response).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(status)) => {
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "error");
|
||||||
|
debug!(
|
||||||
|
addr = %addr,
|
||||||
|
op,
|
||||||
|
tonic_code = ?status.code(),
|
||||||
|
tonic_message = status.message(),
|
||||||
|
"Detached remote lock RPC failed after its caller timed out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(join_error) => {
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_late_completion(op, "join_error");
|
||||||
|
debug!(addr = %addr, op, error = %join_error, "Detached remote lock RPC task ended abnormally");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn late_release_hook(&self, lock_id: LockId) -> LateCompletion<Response<GenerallyLockResponse>> {
|
||||||
|
let client = self.clone();
|
||||||
|
Some(Box::new(move |response: Response<GenerallyLockResponse>| {
|
||||||
|
Box::pin(async move {
|
||||||
|
if response.get_ref().success {
|
||||||
|
client.release_late_acquisitions(vec![lock_id]).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn late_release_batch_hook(&self, lock_ids: Vec<LockId>) -> LateCompletion<Response<BatchGenerallyLockResponse>> {
|
||||||
|
let client = self.clone();
|
||||||
|
Some(Box::new(move |response: Response<BatchGenerallyLockResponse>| {
|
||||||
|
Box::pin(async move {
|
||||||
|
let acquired = acquired_lock_ids(&lock_ids, &response.get_ref().results);
|
||||||
|
if !acquired.is_empty() {
|
||||||
|
client.release_late_acquisitions(acquired).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lock granted after its caller stopped waiting is an orphan until its
|
||||||
|
/// lease expires; hand it back right away, best effort.
|
||||||
|
async fn release_late_acquisitions(&self, lock_ids: Vec<LockId>) {
|
||||||
|
let outcome = match self.release_locks_batch(&lock_ids).await {
|
||||||
|
Ok(released) if released.iter().all(|released| *released) => "released",
|
||||||
|
Ok(_) => "partial",
|
||||||
|
Err(_) => "failed",
|
||||||
|
};
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_late_release(outcome);
|
||||||
|
if outcome == "released" {
|
||||||
|
debug!(addr = %self.addr, count = lock_ids.len(), "Released remote locks granted after their caller timed out");
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
addr = %self.addr,
|
||||||
|
count = lock_ids.len(),
|
||||||
|
outcome,
|
||||||
|
"Could not release every remote lock granted after its caller timed out; the server lease will expire it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_rpc<T, Fut>(
|
||||||
|
&self,
|
||||||
|
op: &'static str,
|
||||||
|
resource_summary: &str,
|
||||||
|
deadline: Duration,
|
||||||
|
future: Fut,
|
||||||
|
late: LateCompletion<T>,
|
||||||
|
) -> std::result::Result<T, LockError>
|
||||||
where
|
where
|
||||||
F: std::future::Future<Output = std::result::Result<T, tonic::Status>>,
|
Fut: Future<Output = std::result::Result<T, tonic::Status>> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
{
|
{
|
||||||
let lock_timeout = Self::rpc_timeout();
|
let mut handle = tokio::spawn(future);
|
||||||
match timeout(lock_timeout, future).await {
|
match timeout(deadline, &mut handle).await {
|
||||||
Ok(Ok(response)) => Ok(response),
|
Ok(Ok(Ok(response))) => {
|
||||||
Ok(Err(err)) => {
|
self.record_rpc_success();
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
Ok(Ok(Err(err))) => {
|
||||||
let reason = err.to_string();
|
let reason = err.to_string();
|
||||||
// Only evict (and re-dial) the cached channel when the failure is a genuine
|
// Only evict (and re-dial) the cached channel when the failure is a genuine
|
||||||
// transport problem. A server-produced application status (auth denied, peer
|
// transport problem. A server-produced application status (auth denied, peer
|
||||||
@@ -217,7 +536,7 @@ impl RemoteClient {
|
|||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
tonic_code = ?err.code(),
|
tonic_code = ?err.code(),
|
||||||
tonic_message = err.message(),
|
tonic_message = err.message(),
|
||||||
@@ -228,7 +547,7 @@ impl RemoteClient {
|
|||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
tonic_code = ?err.code(),
|
tonic_code = ?err.code(),
|
||||||
tonic_message = err.message(),
|
tonic_message = err.message(),
|
||||||
@@ -237,17 +556,29 @@ impl RemoteClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if transport_failure {
|
if transport_failure {
|
||||||
self.evict_connection(op, &reason, resource_summary).await;
|
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Transport, deadline)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
|
Err(LockError::internal(format!("{op} RPC failed: {reason}")))
|
||||||
}
|
}
|
||||||
|
Ok(Err(join_error)) => {
|
||||||
|
warn!(
|
||||||
|
addr = %self.addr,
|
||||||
|
op,
|
||||||
|
resource_summary,
|
||||||
|
error = %join_error,
|
||||||
|
"Remote lock RPC task ended abnormally"
|
||||||
|
);
|
||||||
|
Err(LockError::internal(format!("{op} RPC task failed: {join_error}")))
|
||||||
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let reason = format!("RPC timed out after {:?}", lock_timeout);
|
let reason = format!("RPC timed out after {deadline:?}");
|
||||||
|
rustfs_io_metrics::lock_metrics::record_remote_lock_rpc_timeout(&self.addr, op);
|
||||||
if Self::is_scanner_leader_lock(resource_summary) {
|
if Self::is_scanner_leader_lock(resource_summary) {
|
||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
"Remote lock RPC timed out for scanner leader lock"
|
"Remote lock RPC timed out for scanner leader lock"
|
||||||
);
|
);
|
||||||
@@ -255,13 +586,15 @@ impl RemoteClient {
|
|||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
op,
|
op,
|
||||||
timeout_ms = lock_timeout.as_millis(),
|
timeout_ms = deadline.as_millis(),
|
||||||
resource_summary,
|
resource_summary,
|
||||||
"Remote lock RPC timed out"
|
"Remote lock RPC timed out"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.evict_connection(op, &reason, resource_summary).await;
|
self.maybe_evict_connection(op, &reason, resource_summary, EvictionTrigger::Timeout, deadline)
|
||||||
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), lock_timeout))
|
.await;
|
||||||
|
self.detach_timed_out_rpc(op, resource_summary, handle, late);
|
||||||
|
Err(LockError::timeout(format!("remote lock RPC {op} on {}", self.addr), deadline))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,8 +687,18 @@ impl LockClient for RemoteClient {
|
|||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
let late = self.late_release_hook(request.lock_id.clone());
|
||||||
|
|
||||||
let resp = match self.execute_rpc("lock", &resource_summary, client.lock(req)).await {
|
let resp = match self
|
||||||
|
.execute_rpc(
|
||||||
|
"lock",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.lock(req).await },
|
||||||
|
late,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(resp) => resp.into_inner(),
|
Ok(resp) => resp.into_inner(),
|
||||||
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
|
Err(err @ LockError::Timeout { .. }) => return Ok(Self::rpc_timeout_failure_response(request, &err)),
|
||||||
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
|
Err(err) => return Ok(Self::rpc_failure_response(request, &err)),
|
||||||
@@ -393,9 +736,16 @@ impl LockClient for RemoteClient {
|
|||||||
.collect::<Result<Vec<_>>>()?,
|
.collect::<Result<Vec<_>>>()?,
|
||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
let late = self.late_release_batch_hook(requests.iter().map(|request| request.lock_id.clone()).collect());
|
||||||
|
|
||||||
let resp = match self
|
let resp = match self
|
||||||
.execute_rpc("lock_batch", &resource_summary, client.lock_batch(req))
|
.execute_rpc(
|
||||||
|
"lock_batch",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.lock_batch(req).await },
|
||||||
|
late,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp.into_inner(),
|
Ok(resp) => resp.into_inner(),
|
||||||
@@ -436,7 +786,13 @@ impl LockClient for RemoteClient {
|
|||||||
let mut req = Request::new(GenerallyLockRequest { args: request_string });
|
let mut req = Request::new(GenerallyLockRequest { args: request_string });
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("release", &resource_summary, client.un_lock(req))
|
.execute_rpc(
|
||||||
|
"release",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.un_lock(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -464,7 +820,13 @@ impl LockClient for RemoteClient {
|
|||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("release_batch", &resource_summary, client.un_lock_batch(req))
|
.execute_rpc(
|
||||||
|
"release_batch",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.un_lock_batch(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
|
|
||||||
@@ -486,7 +848,13 @@ impl LockClient for RemoteClient {
|
|||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("refresh", &resource_summary, client.refresh(req))
|
.execute_rpc(
|
||||||
|
"refresh",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.refresh(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -506,7 +874,13 @@ impl LockClient for RemoteClient {
|
|||||||
});
|
});
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.execute_rpc("force_release", &resource_summary, client.force_un_lock(req))
|
.execute_rpc(
|
||||||
|
"force_release",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.force_un_lock(req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_inner();
|
.into_inner();
|
||||||
if let Some(error_info) = resp.error_info {
|
if let Some(error_info) = resp.error_info {
|
||||||
@@ -523,16 +897,26 @@ impl LockClient for RemoteClient {
|
|||||||
let status_request = Self::create_unlock_request(lock_id);
|
let status_request = Self::create_unlock_request(lock_id);
|
||||||
let resource_summary = status_request.resource.to_string();
|
let resource_summary = status_request.resource.to_string();
|
||||||
let mut client = self.get_client().await?;
|
let mut client = self.get_client().await?;
|
||||||
|
let args = serde_json::to_string(&status_request)
|
||||||
|
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?;
|
||||||
|
|
||||||
// Try to acquire a very short-lived lock to test availability
|
// Try to acquire a very short-lived lock to test availability
|
||||||
let mut req = Request::new(GenerallyLockRequest {
|
let mut req = Request::new(GenerallyLockRequest { args: args.clone() });
|
||||||
args: serde_json::to_string(&status_request)
|
|
||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
|
||||||
});
|
|
||||||
attach_lock_mutation_body_digest(&mut req)?;
|
attach_lock_mutation_body_digest(&mut req)?;
|
||||||
|
// A probe lock granted after the deadline must not linger on the peer.
|
||||||
|
let late = self.late_release_hook(lock_id.clone());
|
||||||
|
|
||||||
// Try exclusive lock first with very short timeout
|
// Try exclusive lock first with very short timeout
|
||||||
let resp = match self.execute_rpc("check_status", &resource_summary, client.lock(req)).await {
|
let resp = match self
|
||||||
|
.execute_rpc(
|
||||||
|
"check_status",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.lock(req).await },
|
||||||
|
late,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => response.into_inner(),
|
Ok(response) => response.into_inner(),
|
||||||
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
|
Err(_) => return Ok(Some(Self::unknown_lock_info(lock_id))),
|
||||||
};
|
};
|
||||||
@@ -540,14 +924,19 @@ impl LockClient for RemoteClient {
|
|||||||
if resp.success {
|
if resp.success {
|
||||||
// If we successfully acquired the lock, the resource was free.
|
// If we successfully acquired the lock, the resource was free.
|
||||||
// Immediately release it on a best-effort basis.
|
// Immediately release it on a best-effort basis.
|
||||||
let mut release_req = Request::new(GenerallyLockRequest {
|
let mut release_req = Request::new(GenerallyLockRequest { args });
|
||||||
args: serde_json::to_string(&status_request)
|
|
||||||
.map_err(|e| LockError::internal(format!("Failed to serialize request: {e}")))?,
|
|
||||||
});
|
|
||||||
attach_lock_mutation_body_digest(&mut release_req)?;
|
attach_lock_mutation_body_digest(&mut release_req)?;
|
||||||
let _ = self
|
if let Ok(mut client) = self.get_client().await {
|
||||||
.execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
|
let _ = self
|
||||||
.await;
|
.execute_rpc(
|
||||||
|
"check_status_release",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.un_lock(release_req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
} else {
|
} else {
|
||||||
@@ -582,19 +971,8 @@ impl LockClient for RemoteClient {
|
|||||||
|
|
||||||
async fn is_online(&self) -> bool {
|
async fn is_online(&self) -> bool {
|
||||||
let online_timeout = Self::online_check_timeout();
|
let online_timeout = Self::online_check_timeout();
|
||||||
match timeout(online_timeout, async {
|
let mut client = match timeout(online_timeout, self.get_client()).await {
|
||||||
let mut client = self.get_client().await?;
|
Ok(Ok(client)) => client,
|
||||||
let ping_req = Request::new(Self::build_ping_request());
|
|
||||||
self.execute_rpc("ping", Self::ONLINE_CHECK_RESOURCE, client.ping(ping_req))
|
|
||||||
.await?;
|
|
||||||
Ok::<(), LockError>(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(())) => {
|
|
||||||
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
debug!(
|
debug!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
@@ -602,16 +980,39 @@ impl LockClient for RemoteClient {
|
|||||||
error = %err,
|
error = %err,
|
||||||
"remote lock client online check failed"
|
"remote lock client online check failed"
|
||||||
);
|
);
|
||||||
false
|
return false;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let reason = format!("online check timed out after {:?}", online_timeout);
|
|
||||||
warn!(
|
warn!(
|
||||||
addr = %self.addr,
|
addr = %self.addr,
|
||||||
timeout_ms = online_timeout.as_millis(),
|
timeout_ms = online_timeout.as_millis(),
|
||||||
"remote lock client online check timed out"
|
"remote lock client online check timed out while dialing"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let ping_req = Request::new(Self::build_ping_request());
|
||||||
|
match self
|
||||||
|
.execute_rpc(
|
||||||
|
"ping",
|
||||||
|
Self::ONLINE_CHECK_RESOURCE,
|
||||||
|
online_timeout,
|
||||||
|
async move { client.ping(ping_req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
debug!(addr = %self.addr, timeout_ms = online_timeout.as_millis(), "remote lock client is online");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
debug!(
|
||||||
|
addr = %self.addr,
|
||||||
|
timeout_ms = online_timeout.as_millis(),
|
||||||
|
error = %err,
|
||||||
|
"remote lock client online check failed"
|
||||||
);
|
);
|
||||||
self.evict_connection("ping", &reason, Self::ONLINE_CHECK_RESOURCE).await;
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,6 +1074,232 @@ mod tests {
|
|||||||
.with_priority(LockPriority::Normal)
|
.with_priority(LockPriority::Normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_verdict_distinguishes_slow_peers_from_dead_channels() {
|
||||||
|
let now = Instant::now() + Duration::from_secs(3600);
|
||||||
|
let window = Duration::from_secs(6);
|
||||||
|
let cooldown = Duration::from_secs(5);
|
||||||
|
|
||||||
|
let idle = LockPeerChannelHealth::default();
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&idle, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
);
|
||||||
|
|
||||||
|
let serving = LockPeerChannelHealth {
|
||||||
|
last_success: Some(now - Duration::from_secs(1)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&serving, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::PeerRecentlyServed,
|
||||||
|
"a timeout on a peer that just answered is load, not a dead channel"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&serving, now, EvictionTrigger::Transport, window, cooldown),
|
||||||
|
EvictionVerdict::Evict,
|
||||||
|
"a transport failure is reported by the channel itself and still evicts"
|
||||||
|
);
|
||||||
|
|
||||||
|
let quiet = LockPeerChannelHealth {
|
||||||
|
last_success: Some(now - Duration::from_secs(30)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&quiet, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
);
|
||||||
|
|
||||||
|
let just_evicted = LockPeerChannelHealth {
|
||||||
|
last_eviction: Some(now - Duration::from_secs(1)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&just_evicted, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::CoolingDown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&just_evicted, now, EvictionTrigger::Transport, window, cooldown),
|
||||||
|
EvictionVerdict::CoolingDown
|
||||||
|
);
|
||||||
|
|
||||||
|
let cooled = LockPeerChannelHealth {
|
||||||
|
last_eviction: Some(now - Duration::from_secs(10)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
eviction_verdict(&cooled, now, EvictionTrigger::Timeout, window, cooldown),
|
||||||
|
EvictionVerdict::Evict
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acquired_lock_ids_picks_only_granted_batch_entries() {
|
||||||
|
let lock_ids = vec![
|
||||||
|
LockId::new_unique(&ObjectKey::new("bucket", "a")),
|
||||||
|
LockId::new_unique(&ObjectKey::new("bucket", "b")),
|
||||||
|
LockId::new_unique(&ObjectKey::new("bucket", "c")),
|
||||||
|
];
|
||||||
|
let results = vec![
|
||||||
|
GenerallyLockResult {
|
||||||
|
success: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
GenerallyLockResult {
|
||||||
|
success: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let acquired = acquired_lock_ids(&lock_ids, &results);
|
||||||
|
assert_eq!(
|
||||||
|
acquired,
|
||||||
|
vec![lock_ids[0].clone()],
|
||||||
|
"only granted entries with a matching id are released"
|
||||||
|
);
|
||||||
|
assert!(acquired_lock_ids(&lock_ids, &[]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_timeout_keeps_channel_of_recently_serving_peer() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
with_lock_peer_health(&addr, |health| health.last_success = Some(Instant::now()));
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let response = client
|
||||||
|
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!response.success, "timed out lock acquisition should fail");
|
||||||
|
assert!(
|
||||||
|
runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||||
|
"a peer that served a lock RPC within the liveness window is slow, not gone"
|
||||||
|
);
|
||||||
|
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 1);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_repeated_timeouts_evict_at_most_once_per_cooldown() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars(
|
||||||
|
[
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS, Some("60000")),
|
||||||
|
],
|
||||||
|
async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let request = test_lock_request(Duration::from_millis(5));
|
||||||
|
|
||||||
|
let _ = client.acquire_lock(&request).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||||
|
"the first timeout on a quiet peer evicts the cached channel"
|
||||||
|
);
|
||||||
|
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
let _ = client.acquire_lock(&request).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
runtime_sources::test_node_channel_is_cached(&addr).await,
|
||||||
|
"a second timeout inside the cooldown must not tear the fresh channel down again"
|
||||||
|
);
|
||||||
|
assert_eq!(lock_peer_health_for_test(&addr).consecutive_timeouts, 2);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_detaches_timed_out_rpc_and_reclaims_its_slot() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50"))], async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let _ = client
|
||||||
|
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lock_peer_health_for_test(&addr).detached_rpcs,
|
||||||
|
1,
|
||||||
|
"the timed-out stream keeps running instead of being reset"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The hanging listener drops its socket after two seconds; the detached
|
||||||
|
// task then observes the transport failure and frees its slot.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
while lock_peer_health_for_test(&addr).detached_rpcs != 0 {
|
||||||
|
assert!(Instant::now() < deadline, "detached RPC slot must be reclaimed once the stream ends");
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn test_remote_client_cancels_timed_out_rpc_when_detached_budget_is_exhausted() {
|
||||||
|
ensure_test_rpc_secret();
|
||||||
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
cache_lazy_channel(&addr).await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars(
|
||||||
|
[
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_TIMEOUT_MS, Some("50")),
|
||||||
|
(rustfs_config::ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT, Some("0")),
|
||||||
|
],
|
||||||
|
async {
|
||||||
|
let client = RemoteClient::new(addr.clone());
|
||||||
|
let response = client
|
||||||
|
.acquire_lock(&test_lock_request(Duration::from_millis(5)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(
|
||||||
|
lock_peer_health_for_test(&addr).detached_rpcs,
|
||||||
|
0,
|
||||||
|
"an exhausted detached budget falls back to cancelling the stream"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
accept_task.abort();
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
|
fn lock_mutation_helper_marks_single_and_batch_requests_for_rolling_auth() {
|
||||||
let mut single = Request::new(GenerallyLockRequest {
|
let mut single = Request::new(GenerallyLockRequest {
|
||||||
@@ -714,6 +1341,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -759,6 +1387,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -805,6 +1434,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -842,6 +1472,7 @@ mod tests {
|
|||||||
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
let Some((addr, accept_task)) = spawn_hanging_listener().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
@@ -884,6 +1515,7 @@ mod tests {
|
|||||||
let Some(addr) = closed_listener_addr().await else {
|
let Some(addr) = closed_listener_addr().await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
reset_lock_peer_health_for_test(&addr);
|
||||||
cache_lazy_channel(&addr).await;
|
cache_lazy_channel(&addr).await;
|
||||||
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
assert!(runtime_sources::test_node_channel_is_cached(&addr).await);
|
||||||
|
|
||||||
|
|||||||
+2832
-150
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
// #730: data-movement migration keeps staged cleanup helpers until copy paths converge.
|
// #730: data-movement migration keeps staged cleanup helpers until copy paths converge.
|
||||||
|
|
||||||
pub(crate) mod backpressure;
|
pub(crate) mod backpressure;
|
||||||
|
pub(crate) mod scanner_backlog;
|
||||||
|
|
||||||
use crate::core::pools::{DecommissionCapacityOwner, decommission_capacity_mutation_id};
|
use crate::core::pools::{DecommissionCapacityOwner, decommission_capacity_mutation_id};
|
||||||
use crate::error::{
|
use crate::error::{
|
||||||
@@ -1199,7 +1200,7 @@ struct SourceCleanupDeleteBarrierState {
|
|||||||
dead_code,
|
dead_code,
|
||||||
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
|
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
|
||||||
)]
|
)]
|
||||||
pub(crate) struct SourceCleanupDeleteBarrier {
|
pub struct SourceCleanupDeleteBarrier {
|
||||||
state: Arc<SourceCleanupDeleteBarrierState>,
|
state: Arc<SourceCleanupDeleteBarrierState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,7 +1214,7 @@ static SOURCE_CLEANUP_DELETE_BARRIERS: std::sync::OnceLock<std::sync::Mutex<Vec<
|
|||||||
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
|
reason = "installed by set_disk object tests behind `--features test-util` (backlog#1823)"
|
||||||
)]
|
)]
|
||||||
impl SourceCleanupDeleteBarrier {
|
impl SourceCleanupDeleteBarrier {
|
||||||
pub(crate) fn install(bucket: &str, object: &str) -> Self {
|
pub fn install(bucket: &str, object: &str) -> Self {
|
||||||
let state = Arc::new(SourceCleanupDeleteBarrierState {
|
let state = Arc::new(SourceCleanupDeleteBarrierState {
|
||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
object: object.to_string(),
|
object: object.to_string(),
|
||||||
@@ -1236,7 +1237,7 @@ impl SourceCleanupDeleteBarrier {
|
|||||||
Self { state }
|
Self { state }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn wait_until_paused(&self) {
|
pub async fn wait_until_paused(&self) {
|
||||||
tokio::time::timeout(StdDuration::from_secs(30), self.state.arrived.notified())
|
tokio::time::timeout(StdDuration::from_secs(30), self.state.arrived.notified())
|
||||||
.await
|
.await
|
||||||
.expect("source cleanup should reach the pre-delete barrier");
|
.expect("source cleanup should reach the pre-delete barrier");
|
||||||
@@ -1252,7 +1253,7 @@ impl SourceCleanupDeleteBarrier {
|
|||||||
self.state.is_paused.load(Ordering::Acquire)
|
self.state.is_paused.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn release(&self) {
|
pub fn release(&self) {
|
||||||
self.state.release.notify_one();
|
self.state.release.notify_one();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1431,7 +1432,8 @@ fn resolve_data_movement_overwrite_resume_result_for(
|
|||||||
target_pool_idx: usize,
|
target_pool_idx: usize,
|
||||||
compare_part_checksums: bool,
|
compare_part_checksums: bool,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
if !should_check_data_movement_overwrite_resume(err)
|
if scanner_backlog::is_scanner_pause_backlog(&source.bucket, &source.name)
|
||||||
|
|| !should_check_data_movement_overwrite_resume(err)
|
||||||
|| !should_check_data_movement_resume_target(src_pool_idx, target_pool_idx)
|
|| !should_check_data_movement_resume_target(src_pool_idx, target_pool_idx)
|
||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -1501,9 +1503,27 @@ fn data_movement_part_stage_error(
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
part_number: usize,
|
part_number: usize,
|
||||||
err: impl std::fmt::Display,
|
err: Error,
|
||||||
) -> Error {
|
) -> Error {
|
||||||
Error::other(format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}"))
|
let rendered = format!("{op_label}: {stage} failed for {bucket}/{object} part {part_number}: {err}");
|
||||||
|
if matches!(&err, Error::DecommissionCapacityBlocked { .. }) {
|
||||||
|
return data_movement_context_error(rendered, err);
|
||||||
|
}
|
||||||
|
// A missing target part is not evidence that the source can be deleted.
|
||||||
|
// Keep other part errors opaque to the source-cleanup classifiers.
|
||||||
|
Error::other(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn data_movement_part_stage_error_for_test(
|
||||||
|
op_label: &str,
|
||||||
|
stage: &str,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
part_number: usize,
|
||||||
|
err: Error,
|
||||||
|
) -> Error {
|
||||||
|
data_movement_part_stage_error(op_label, stage, bucket, object, part_number, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_data_movement_part_read_error(err: &Error) -> bool {
|
fn is_data_movement_part_read_error(err: &Error) -> bool {
|
||||||
@@ -1608,6 +1628,9 @@ async fn migrate_object_inner(
|
|||||||
capacity_owner: Option<DecommissionCapacityOwner>,
|
capacity_owner: Option<DecommissionCapacityOwner>,
|
||||||
mutation_fence: Option<DecommissionFixedReadAnchor>,
|
mutation_fence: Option<DecommissionFixedReadAnchor>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
if scanner_backlog::is_scanner_pause_backlog(&bucket, &rd.object_info.name) {
|
||||||
|
return Err(Error::other("scanner pause backlog requires native retirement handoff"));
|
||||||
|
}
|
||||||
let mut mutation_fence = mutation_fence;
|
let mut mutation_fence = mutation_fence;
|
||||||
let object_info = rd.object_info.clone();
|
let object_info = rd.object_info.clone();
|
||||||
let capacity_owner = capacity_owner.map(|owner| {
|
let capacity_owner = capacity_owner.map(|owner| {
|
||||||
@@ -2408,8 +2431,15 @@ mod tests {
|
|||||||
let err =
|
let err =
|
||||||
data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown);
|
data_movement_part_stage_error("rebalance_object", "put_object_part", "bucket-a", "object-a", 7, Error::SlowDown);
|
||||||
let message = err.to_string();
|
let message = err.to_string();
|
||||||
assert!(message.contains("rebalance_object: put_object_part failed for bucket-a/object-a part 7"));
|
assert_eq!(
|
||||||
assert!(message.contains(Error::SlowDown.to_string().as_str()));
|
message,
|
||||||
|
Error::other(format!(
|
||||||
|
"rebalance_object: put_object_part failed for bucket-a/object-a part 7: {}",
|
||||||
|
Error::SlowDown
|
||||||
|
))
|
||||||
|
.to_string()
|
||||||
|
);
|
||||||
|
assert!(data_movement_stage_source(&err).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3288,6 +3318,141 @@ mod tests {
|
|||||||
assert!(overwrite_resume_for_target(&source, source.clone()));
|
assert!(overwrite_resume_for_target(&source, source.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scanner_backlog_replica_pair() -> (ObjectInfo, ObjectInfo) {
|
||||||
|
let source = ObjectInfo {
|
||||||
|
bucket: crate::disk::RUSTFS_META_BUCKET.to_string(),
|
||||||
|
name: "buckets/.scanner-pause-backlog.json".to_string(),
|
||||||
|
version_id: None,
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND),
|
||||||
|
..overwrite_equivalence_source()
|
||||||
|
};
|
||||||
|
let target = ObjectInfo {
|
||||||
|
mod_time: Some(OffsetDateTime::UNIX_EPOCH),
|
||||||
|
..source.clone()
|
||||||
|
};
|
||||||
|
(source, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_backlog_precondition_resumes(source: &ObjectInfo, target: ObjectInfo) -> bool {
|
||||||
|
resolve_data_movement_overwrite_resume_result_for(&Error::PreconditionFailed, Ok(Some(target)), source, 0, 1, true)
|
||||||
|
.expect("scanner replica conflict should be adjudicated")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_requires_native_cohort_proof_even_for_identical_payload() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
assert!(!is_owned_data_movement_target(&target), "native scanner writes are not migration copies");
|
||||||
|
assert!(!is_equivalent_data_movement_object(&source, &target));
|
||||||
|
assert!(
|
||||||
|
!scanner_backlog_precondition_resumes(&source, target),
|
||||||
|
"a single identical replica cannot prove native cohort authority"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_rejects_newer_timestamp_and_full_single_replica_identity() {
|
||||||
|
let (source, mut target) = scanner_backlog_replica_pair();
|
||||||
|
target.mod_time = source.mod_time.map(|time| time + time::Duration::SECOND);
|
||||||
|
target.etag = Some("different-native-ledger".to_string());
|
||||||
|
assert!(!scanner_backlog_precondition_resumes(&source, target));
|
||||||
|
assert!(!scanner_backlog_precondition_resumes(&source, source.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_rejects_changed_payload_or_metadata() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
let mut different_etag = target.clone();
|
||||||
|
different_etag.etag = Some("different-ledger-generation".to_string());
|
||||||
|
let mut different_size = target.clone();
|
||||||
|
different_size.size += 1;
|
||||||
|
let mut different_checksum = target.clone();
|
||||||
|
different_checksum.checksum = Some(Bytes::from_static(b"different-checksum"));
|
||||||
|
let mut different_metadata = target.clone();
|
||||||
|
Arc::make_mut(&mut different_metadata.user_defined).insert("x-amz-meta-key".to_string(), "different".to_string());
|
||||||
|
let mut different_tags = target.clone();
|
||||||
|
different_tags.user_tags = Arc::new("tag=changed".to_string());
|
||||||
|
let mut different_parts = target.clone();
|
||||||
|
Arc::make_mut(&mut different_parts.parts)[0].etag = "different-part".to_string();
|
||||||
|
let mut different_tier = target;
|
||||||
|
different_tier.transitioned_object.tier = "different-tier".to_string();
|
||||||
|
for (label, different) in [
|
||||||
|
("etag", different_etag),
|
||||||
|
("size", different_size),
|
||||||
|
("checksum", different_checksum),
|
||||||
|
("metadata", different_metadata),
|
||||||
|
("tags", different_tags),
|
||||||
|
("parts", different_parts),
|
||||||
|
("tier", different_tier),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!scanner_backlog_precondition_resumes(&source, different),
|
||||||
|
"replica-local timestamps do not authorize a changed {label}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_rejects_other_namespaces_and_incomplete_identity() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
for (bucket, name) in [
|
||||||
|
("user-bucket", "buckets/.scanner-pause-backlog.json"),
|
||||||
|
(crate::disk::RUSTFS_META_BUCKET, "buckets/.scanner-pause-backlog.json.bkp"),
|
||||||
|
(crate::disk::RUSTFS_META_BUCKET, "buckets/.usage-cache.bin"),
|
||||||
|
] {
|
||||||
|
let mut source = source.clone();
|
||||||
|
let mut target = target.clone();
|
||||||
|
for replica in [&mut source, &mut target] {
|
||||||
|
replica.bucket = bucket.to_string();
|
||||||
|
replica.name = name.to_string();
|
||||||
|
}
|
||||||
|
assert!(!scanner_backlog_precondition_resumes(&source, target), "out-of-scope key {bucket}/{name}");
|
||||||
|
}
|
||||||
|
for missing in ["etag", "empty-etag", "source-time", "target-time", "version", "delete-marker"] {
|
||||||
|
let mut source = source.clone();
|
||||||
|
let mut target = target.clone();
|
||||||
|
match missing {
|
||||||
|
"etag" => {
|
||||||
|
source.etag = None;
|
||||||
|
target.etag = None;
|
||||||
|
}
|
||||||
|
"empty-etag" => {
|
||||||
|
source.etag = Some(String::new());
|
||||||
|
target.etag = Some(String::new());
|
||||||
|
}
|
||||||
|
"source-time" => source.mod_time = None,
|
||||||
|
"target-time" => target.mod_time = None,
|
||||||
|
"version" => {
|
||||||
|
source.version_id = Some(Uuid::from_u128(1));
|
||||||
|
target.version_id = source.version_id;
|
||||||
|
}
|
||||||
|
"delete-marker" => {
|
||||||
|
source.delete_marker = true;
|
||||||
|
target.delete_marker = true;
|
||||||
|
}
|
||||||
|
_ => unreachable!("all identity variants are enumerated above"),
|
||||||
|
}
|
||||||
|
assert!(!scanner_backlog_precondition_resumes(&source, target), "unsupported identity: {missing}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scanner_backlog_resume_requires_a_cross_pool_precondition_conflict() {
|
||||||
|
let (source, target) = scanner_backlog_replica_pair();
|
||||||
|
for (err, target_pool) in [
|
||||||
|
(Error::PreconditionFailed, 0),
|
||||||
|
(Error::SlowDown, 1),
|
||||||
|
(
|
||||||
|
Error::InvalidUploadID(source.bucket.clone(), source.name.clone(), "upload".to_string()),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!resolve_data_movement_overwrite_resume_result_for(&err, Ok(Some(target.clone())), &source, 0, target_pool, true)
|
||||||
|
.expect("non-resumable conflict should return false")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
|
fn test_data_movement_overwrite_resume_accepts_part_mod_time_drift() {
|
||||||
let source = overwrite_equivalence_source();
|
let source = overwrite_equivalence_source();
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use crate::disk::RUSTFS_META_BUCKET;
|
||||||
|
use crate::error::{Error, Result, is_err_object_not_found, is_err_version_not_found};
|
||||||
|
use crate::object_api::ObjectOptions;
|
||||||
|
use crate::object_api::{ObjectInfo, PutObjReader, WriteCompletion};
|
||||||
|
use crate::set_disk::SetDisks;
|
||||||
|
use crate::storage_api_contracts::object::HTTPPreconditions;
|
||||||
|
use crate::storage_api_contracts::object::ObjectIO as _;
|
||||||
|
use futures::future::join_all;
|
||||||
|
use http::HeaderMap;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
pub const MAX_SCANNER_PAUSE_BACKLOG_BYTES: u64 = 64 * 1024;
|
||||||
|
pub(crate) const SCANNER_PAUSE_BACKLOG_PATH: &str = "buckets/.scanner-pause-backlog.json";
|
||||||
|
|
||||||
|
/// A bounded, storage-fenced native replica. Only a confirmed missing object
|
||||||
|
/// has no payload; read failures never enter the Scanner verifier.
|
||||||
|
pub struct ScannerPauseBacklogRetirementReplica {
|
||||||
|
pub pool_index: usize,
|
||||||
|
pub set_index: usize,
|
||||||
|
pub data: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native records for a membership handoff. Existing durable ledgers are
|
||||||
|
/// preserved; an empty native bootstrap may initialize its first ledger.
|
||||||
|
pub struct ScannerPauseBacklogRetirementPlan {
|
||||||
|
pub seed_record: Option<Vec<u8>>,
|
||||||
|
pub commit_record: Vec<u8>,
|
||||||
|
pub stable_record: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ScannerPauseBacklogRetirementPlanner =
|
||||||
|
fn(usize, &[ScannerPauseBacklogRetirementReplica]) -> std::result::Result<Option<ScannerPauseBacklogRetirementPlan>, String>;
|
||||||
|
|
||||||
|
static RETIREMENT_PLANNER: OnceLock<ScannerPauseBacklogRetirementPlanner> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Install the stateless native record planner before storage starts workers.
|
||||||
|
/// The scanner runtime switch does not control this storage safety check.
|
||||||
|
pub fn register_scanner_pause_backlog_retirement_planner(planner: ScannerPauseBacklogRetirementPlanner) {
|
||||||
|
RETIREMENT_PLANNER.get_or_init(|| planner);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_scanner_pause_backlog(bucket: &str, object: &str) -> bool {
|
||||||
|
bucket == RUSTFS_META_BUCKET && object == SCANNER_PAUSE_BACKLOG_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ScannerPauseBacklogRetirementRead {
|
||||||
|
pub replica: ScannerPauseBacklogRetirementReplica,
|
||||||
|
pub etag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScannerPauseBacklogRetirementRead {
|
||||||
|
pub(crate) fn preconditions(&self) -> HTTPPreconditions {
|
||||||
|
match &self.etag {
|
||||||
|
Some(etag) => HTTPPreconditions {
|
||||||
|
if_match: Some(etag.clone()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None => HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetirementRead> {
|
||||||
|
let mut replica = ScannerPauseBacklogRetirementReplica {
|
||||||
|
pool_index: set.pool_index,
|
||||||
|
set_index: set.set_index,
|
||||||
|
data: None,
|
||||||
|
};
|
||||||
|
let reader = match set
|
||||||
|
.get_object_reader(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
SCANNER_PAUSE_BACKLOG_PATH,
|
||||||
|
None,
|
||||||
|
HeaderMap::new(),
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(reader) => reader,
|
||||||
|
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
|
||||||
|
return Ok(ScannerPauseBacklogRetirementRead { replica, etag: None });
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
let info = &reader.object_info;
|
||||||
|
if info.version_id.is_some_and(|version| !version.is_nil())
|
||||||
|
|| info.delete_marker
|
||||||
|
|| info.is_dir
|
||||||
|
|| info.etag.as_ref().is_none_or(String::is_empty)
|
||||||
|
|| info.size < 0
|
||||||
|
|| info.size > MAX_SCANNER_PAUSE_BACKLOG_BYTES as i64
|
||||||
|
{
|
||||||
|
return Err(Error::other("scanner pause backlog retirement found an unsupported replica identity"));
|
||||||
|
}
|
||||||
|
let etag = info.etag.clone();
|
||||||
|
let expected_size = info.size as usize;
|
||||||
|
let mut data = Vec::new();
|
||||||
|
reader
|
||||||
|
.take(MAX_SCANNER_PAUSE_BACKLOG_BYTES + 1)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.await?;
|
||||||
|
if data.len() != expected_size || data.len() > MAX_SCANNER_PAUSE_BACKLOG_BYTES as usize {
|
||||||
|
return Err(Error::other("scanner pause backlog retirement replica has an invalid payload length"));
|
||||||
|
}
|
||||||
|
replica.data = Some(data);
|
||||||
|
Ok(ScannerPauseBacklogRetirementRead { replica, etag })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The caller retains the fixed object write lock and durable topology read
|
||||||
|
/// fence through both this snapshot and physical source cleanup.
|
||||||
|
pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
|
||||||
|
source_pool_index: usize,
|
||||||
|
source_set_index: usize,
|
||||||
|
sets: Vec<Arc<SetDisks>>,
|
||||||
|
) -> Result<Vec<ScannerPauseBacklogRetirementRead>> {
|
||||||
|
let replicas = join_all(sets.into_iter().map(read_replica))
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
if !replicas.iter().any(|read| {
|
||||||
|
read.replica.pool_index == source_pool_index && read.replica.set_index == source_set_index && read.replica.data.is_some()
|
||||||
|
}) {
|
||||||
|
return Err(Error::other("scanner pause backlog retirement current source replica is missing"));
|
||||||
|
}
|
||||||
|
Ok(replicas)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn plan_scanner_pause_backlog_retirement(
|
||||||
|
source_pool_index: usize,
|
||||||
|
replicas: &[ScannerPauseBacklogRetirementRead],
|
||||||
|
) -> Result<Option<ScannerPauseBacklogRetirementPlan>> {
|
||||||
|
let planner = RETIREMENT_PLANNER
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| Error::other("scanner pause backlog native retirement planner is unavailable"))?;
|
||||||
|
let snapshots = replicas
|
||||||
|
.iter()
|
||||||
|
.map(|read| ScannerPauseBacklogRetirementReplica {
|
||||||
|
pool_index: read.replica.pool_index,
|
||||||
|
set_index: read.replica.set_index,
|
||||||
|
data: read.replica.data.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
planner(source_pool_index, &snapshots).map_err(Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The native writer and retirement handoff use the same conditional, full-tail
|
||||||
|
/// write. Their callers retain object and durable membership fences until return.
|
||||||
|
pub(crate) async fn persist_native_scanner_pause_backlog_replica(
|
||||||
|
set: Arc<SetDisks>,
|
||||||
|
data: Vec<u8>,
|
||||||
|
preconditions: HTTPPreconditions,
|
||||||
|
mut opts: ObjectOptions,
|
||||||
|
_phase: &'static str,
|
||||||
|
) -> Result<ObjectInfo> {
|
||||||
|
if data.len() > MAX_SCANNER_PAUSE_BACKLOG_BYTES as usize {
|
||||||
|
return Err(Error::other("scanner pause backlog exceeds its size bound"));
|
||||||
|
}
|
||||||
|
opts.max_parity = true;
|
||||||
|
opts.write_completion = WriteCompletion::TailDrained;
|
||||||
|
opts.http_preconditions = Some(preconditions);
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
let fault = test_util::matching_write(&set, _phase)?;
|
||||||
|
let result = set
|
||||||
|
.put_object(RUSTFS_META_BUCKET, SCANNER_PAUSE_BACKLOG_PATH, &mut PutObjReader::from_vec(data), &opts)
|
||||||
|
.await;
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
if result.is_ok()
|
||||||
|
&& let Some(fault) = fault
|
||||||
|
{
|
||||||
|
fault.arrived.notify_one();
|
||||||
|
fault.release.notified().await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub mod test_util {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("injected native scanner backlog {phase} write failure")]
|
||||||
|
struct InjectedWriteFailure {
|
||||||
|
phase: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct WriteFault {
|
||||||
|
set: Arc<SetDisks>,
|
||||||
|
phase: &'static str,
|
||||||
|
remaining: AtomicUsize,
|
||||||
|
fail_before_write: bool,
|
||||||
|
pub(super) arrived: Notify,
|
||||||
|
pub(super) release: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
static WRITE_FAULTS: Mutex<Vec<Arc<WriteFault>>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Scope a one-shot fault to the actual set instance, so other stores and
|
||||||
|
/// concurrent tests keep using the ordinary native persistence path.
|
||||||
|
pub struct NativeScannerPauseBacklogWriteFault {
|
||||||
|
state: Arc<WriteFault>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeScannerPauseBacklogWriteFault {
|
||||||
|
fn install(set: Arc<SetDisks>, phase: &'static str, nth: usize, fail_before_write: bool) -> Self {
|
||||||
|
assert!(nth > 0);
|
||||||
|
let state = Arc::new(WriteFault {
|
||||||
|
set,
|
||||||
|
phase,
|
||||||
|
remaining: AtomicUsize::new(nth),
|
||||||
|
fail_before_write,
|
||||||
|
arrived: Notify::new(),
|
||||||
|
release: Notify::new(),
|
||||||
|
});
|
||||||
|
let mut faults = WRITE_FAULTS.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
!faults
|
||||||
|
.iter()
|
||||||
|
.any(|fault| Arc::ptr_eq(&fault.set, &state.set) && fault.phase == phase)
|
||||||
|
);
|
||||||
|
faults.push(Arc::clone(&state));
|
||||||
|
Self { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail_before_write(set: Arc<SetDisks>, phase: &'static str, nth: usize) -> Self {
|
||||||
|
Self::install(set, phase, nth, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause_after_write(set: Arc<SetDisks>, phase: &'static str) -> Self {
|
||||||
|
Self::install(set, phase, 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_until_paused(&self) {
|
||||||
|
self.state.arrived.notified().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(&self) {
|
||||||
|
self.state.release.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for NativeScannerPauseBacklogWriteFault {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.release();
|
||||||
|
WRITE_FAULTS.lock().unwrap().retain(|fault| !Arc::ptr_eq(fault, &self.state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn matching_write(set: &Arc<SetDisks>, phase: &'static str) -> Result<Option<Arc<WriteFault>>> {
|
||||||
|
let fault = WRITE_FAULTS
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|fault| Arc::ptr_eq(&fault.set, set) && fault.phase == phase)
|
||||||
|
.cloned();
|
||||||
|
let Some(fault) = fault else { return Ok(None) };
|
||||||
|
if fault
|
||||||
|
.remaining
|
||||||
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| remaining.checked_sub(1))
|
||||||
|
!= Ok(1)
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if fault.fail_before_write {
|
||||||
|
return Err(Error::other(InjectedWriteFailure { phase }));
|
||||||
|
}
|
||||||
|
Ok(Some(fault))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3397,6 +3397,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([1; 32]),
|
scan_plan_digest: Some([1; 32]),
|
||||||
complete: false,
|
complete: false,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
}];
|
}];
|
||||||
partial.buckets_usage.insert(
|
partial.buckets_usage.insert(
|
||||||
"bucket".to_string(),
|
"bucket".to_string(),
|
||||||
@@ -3469,6 +3470,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([1; 32]),
|
scan_plan_digest: Some([1; 32]),
|
||||||
complete: true,
|
complete: true,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -627,7 +627,13 @@ impl From<tokio::task::JoinError> for DiskError {
|
|||||||
impl Clone for DiskError {
|
impl Clone for DiskError {
|
||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
DiskError::Io(io_error) => DiskError::Io(std::io::Error::new(io_error.kind(), io_error.to_string())),
|
DiskError::Io(io_error) => DiskError::Io(
|
||||||
|
rustfs_rio::clone_internode_http_io_error(io_error)
|
||||||
|
.and_then(std::io::Error::into_inner)
|
||||||
|
// The helper derives a kind from the source; Clone must retain the original outer kind.
|
||||||
|
.map(|source| std::io::Error::new(io_error.kind(), source))
|
||||||
|
.unwrap_or_else(|| std::io::Error::new(io_error.kind(), io_error.to_string())),
|
||||||
|
),
|
||||||
DiskError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
DiskError::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
|
||||||
DiskError::Unexpected => DiskError::Unexpected,
|
DiskError::Unexpected => DiskError::Unexpected,
|
||||||
DiskError::CorruptedFormat => DiskError::CorruptedFormat,
|
DiskError::CorruptedFormat => DiskError::CorruptedFormat,
|
||||||
@@ -1265,6 +1271,49 @@ mod tests {
|
|||||||
assert!(!bad_request.is_retryable_internode_write_failure());
|
assert!(!bad_request.is_retryable_internode_write_failure());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_internode_http_clone_preserves_retryability_status_and_context() {
|
||||||
|
use http::StatusCode;
|
||||||
|
use rustfs_rio::InternodeHttpErrorKind::{ConnectionRefused, ConnectionReset, HttpStatus, Unknown};
|
||||||
|
|
||||||
|
for (kind, retryable) in [
|
||||||
|
(ConnectionRefused, true),
|
||||||
|
(ConnectionReset, true),
|
||||||
|
(HttpStatus(StatusCode::TOO_MANY_REQUESTS), true),
|
||||||
|
(HttpStatus(StatusCode::SERVICE_UNAVAILABLE), true),
|
||||||
|
(HttpStatus(StatusCode::CONFLICT), true),
|
||||||
|
(Unknown, false),
|
||||||
|
(HttpStatus(StatusCode::BAD_REQUEST), false),
|
||||||
|
(HttpStatus(StatusCode::INTERNAL_SERVER_ERROR), false),
|
||||||
|
] {
|
||||||
|
let original = DiskError::from(rustfs_rio::new_test_internode_http_io_error(kind));
|
||||||
|
assert_eq!(original.internode_http_error_kind(), Some(kind));
|
||||||
|
assert_eq!(original.is_retryable_internode_write_failure(), retryable);
|
||||||
|
|
||||||
|
let cloned = original.clone();
|
||||||
|
assert_eq!(cloned, original, "clone must preserve the error bucket for {kind:?}");
|
||||||
|
assert_eq!(
|
||||||
|
cloned.is_retryable_internode_write_failure(),
|
||||||
|
retryable,
|
||||||
|
"clone changed retryability for {kind:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(cloned.internode_http_error_kind(), Some(kind));
|
||||||
|
if let HttpStatus(status) = kind {
|
||||||
|
assert!(cloned.is_internode_http_status(status.as_u16()));
|
||||||
|
}
|
||||||
|
let DiskError::Io(io_error) = &cloned else {
|
||||||
|
panic!("unmarked internode error must remain Io: {cloned:?}");
|
||||||
|
};
|
||||||
|
let source = io_error
|
||||||
|
.get_ref()
|
||||||
|
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
|
||||||
|
.expect("clone must retain the structured internode error");
|
||||||
|
assert_eq!(source.context().method(), "PUT");
|
||||||
|
assert_eq!(source.context().target(), "/rustfs/rpc/put_file_stream");
|
||||||
|
assert_eq!(source.context().operation(), Some(INTERNODE_OPERATION_PUT_FILE_STREAM));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_stream_conflict_is_not_a_retryable_put_file_failure() {
|
async fn read_stream_conflict_is_not_a_retryable_put_file_failure() {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
@@ -1309,11 +1358,57 @@ mod tests {
|
|||||||
!error.is_retryable_internode_write_failure(),
|
!error.is_retryable_internode_write_failure(),
|
||||||
"read-operation 409 must not trigger put-file retry"
|
"read-operation 409 must not trigger put-file retry"
|
||||||
);
|
);
|
||||||
|
let cloned = error.clone();
|
||||||
|
let reduced = crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(error)], &[], 1)
|
||||||
|
.expect("the read conflict must remain the dominant error");
|
||||||
|
for preserved in [&cloned, &reduced] {
|
||||||
|
assert!(
|
||||||
|
!preserved.is_retryable_internode_write_failure(),
|
||||||
|
"cloning or reducing a read conflict must not turn it into a PUT retry"
|
||||||
|
);
|
||||||
|
assert!(preserved.is_internode_http_status(409));
|
||||||
|
let DiskError::Io(io_error) = preserved else {
|
||||||
|
panic!("read conflict must remain Io: {preserved:?}");
|
||||||
|
};
|
||||||
|
let source = io_error
|
||||||
|
.get_ref()
|
||||||
|
.and_then(|source| source.downcast_ref::<InternodeHttpError>())
|
||||||
|
.expect("read conflict must retain its request context");
|
||||||
|
assert_eq!(source.context().method(), "GET");
|
||||||
|
assert_eq!(source.context().target(), "/rustfs/rpc/read_file_stream");
|
||||||
|
assert_eq!(
|
||||||
|
source.context().operation(),
|
||||||
|
Some(rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_READ_FILE_STREAM)
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("isolated read-conflict test must finish within its budget");
|
.expect("isolated read-conflict test must finish within its budget");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_internode_http_clone_preserves_outer_io_kind_and_message() {
|
||||||
|
let source = rustfs_rio::new_test_internode_http_io_error(InternodeHttpErrorKind::ConnectionReset)
|
||||||
|
.into_inner()
|
||||||
|
.expect("the internode helper must provide a typed source");
|
||||||
|
let original_io = io::Error::new(io::ErrorKind::InvalidData, source);
|
||||||
|
let message = original_io.to_string();
|
||||||
|
let original = DiskError::from(original_io);
|
||||||
|
assert_eq!(original.internode_http_error_kind(), Some(InternodeHttpErrorKind::ConnectionReset));
|
||||||
|
assert!(original.is_retryable_internode_write_failure());
|
||||||
|
|
||||||
|
let cloned = original.clone();
|
||||||
|
let reduced = crate::disk::error_reduce::reduce_write_quorum_errs(&[Some(original)], &[], 1)
|
||||||
|
.expect("the wrapped internode error must remain the dominant error");
|
||||||
|
for preserved in [&cloned, &reduced] {
|
||||||
|
let DiskError::Io(io_error) = preserved else {
|
||||||
|
panic!("the wrapped error must remain Io: {preserved:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(io_error.kind(), io::ErrorKind::InvalidData);
|
||||||
|
assert_eq!(io_error.to_string(), message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_internode_missing_errors_preserve_disk_error_types() {
|
fn test_internode_missing_errors_preserve_disk_error_types() {
|
||||||
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
|
let file_missing = DiskError::from(rustfs_rio::new_test_remote_file_not_found_http_io_error());
|
||||||
@@ -1325,6 +1420,17 @@ mod tests {
|
|||||||
assert_eq!(file_missing, DiskError::FileNotFound);
|
assert_eq!(file_missing, DiskError::FileNotFound);
|
||||||
assert_eq!(volume_missing, DiskError::VolumeNotFound);
|
assert_eq!(volume_missing, DiskError::VolumeNotFound);
|
||||||
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
|
assert!(matches!(unmarked_server_error, DiskError::Io(_)));
|
||||||
|
for missing in [file_missing, volume_missing] {
|
||||||
|
assert_eq!(missing.clone(), missing);
|
||||||
|
assert_eq!(
|
||||||
|
crate::disk::error_reduce::reduce_write_quorum_errs(
|
||||||
|
&[Some(missing.clone()), Some(missing.clone()), None],
|
||||||
|
&[],
|
||||||
|
2
|
||||||
|
),
|
||||||
|
Some(missing)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -226,6 +226,78 @@ mod tests {
|
|||||||
assert_eq!(res, Some(quorum_err));
|
assert_eq!(res, Some(quorum_err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_write_quorum_reduction_preserves_internode_http_identity() {
|
||||||
|
use http::StatusCode;
|
||||||
|
use rustfs_rio::InternodeHttpErrorKind::{ConnectionRefused, HttpStatus, Unknown};
|
||||||
|
|
||||||
|
for (kind, retryable) in [
|
||||||
|
(ConnectionRefused, true),
|
||||||
|
(HttpStatus(StatusCode::SERVICE_UNAVAILABLE), true),
|
||||||
|
(HttpStatus(StatusCode::CONFLICT), true),
|
||||||
|
(Unknown, false),
|
||||||
|
(HttpStatus(StatusCode::BAD_REQUEST), false),
|
||||||
|
] {
|
||||||
|
// Construct both producer errors independently: the reducer owns the first clone.
|
||||||
|
let first = Error::from(rustfs_rio::new_test_internode_http_io_error(kind));
|
||||||
|
let second = Error::from(rustfs_rio::new_test_internode_http_io_error(kind));
|
||||||
|
assert_eq!(first.internode_http_error_kind(), Some(kind));
|
||||||
|
assert_eq!(second.internode_http_error_kind(), Some(kind));
|
||||||
|
assert_eq!(first.is_retryable_internode_write_failure(), retryable);
|
||||||
|
let errors = [Some(first), Some(second), None];
|
||||||
|
let reduced = reduce_write_quorum_errs(&errors, OBJECT_OP_IGNORED_ERRS, 2)
|
||||||
|
.expect("two equal producer errors must dominate one successful write");
|
||||||
|
|
||||||
|
assert_eq!(Some(&reduced), errors[0].as_ref());
|
||||||
|
assert_eq!(
|
||||||
|
reduced.is_retryable_internode_write_failure(),
|
||||||
|
retryable,
|
||||||
|
"quorum reduction changed retryability for {kind:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(reduced.internode_http_error_kind(), Some(kind));
|
||||||
|
if let HttpStatus(status) = kind {
|
||||||
|
assert!(reduced.is_internode_http_status(status.as_u16()));
|
||||||
|
}
|
||||||
|
let Error::Io(io_error) = &reduced else {
|
||||||
|
panic!("the dominant error must remain Io: {reduced:?}");
|
||||||
|
};
|
||||||
|
let source = io_error
|
||||||
|
.get_ref()
|
||||||
|
.and_then(|source| source.downcast_ref::<rustfs_rio::InternodeHttpError>())
|
||||||
|
.expect("quorum reduction must retain the structured internode error");
|
||||||
|
assert_eq!(source.context().method(), "PUT");
|
||||||
|
assert_eq!(source.context().target(), "/rustfs/rpc/put_file_stream");
|
||||||
|
assert_eq!(
|
||||||
|
source.context().operation(),
|
||||||
|
Some(rustfs_io_metrics::internode_metrics::INTERNODE_OPERATION_PUT_FILE_STREAM)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clone_and_write_quorum_do_not_promote_non_retryable_errors() {
|
||||||
|
use http::StatusCode;
|
||||||
|
use rustfs_rio::InternodeHttpErrorKind::{HttpStatus, Unknown};
|
||||||
|
|
||||||
|
for original in [
|
||||||
|
Error::from(rustfs_rio::new_test_internode_http_io_error(Unknown)),
|
||||||
|
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::BAD_REQUEST))),
|
||||||
|
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::FORBIDDEN))),
|
||||||
|
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(StatusCode::NOT_FOUND))),
|
||||||
|
Error::from(rustfs_rio::new_test_internode_http_io_error(HttpStatus(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
))),
|
||||||
|
err_io("internode connection reset: PUT /rustfs/rpc/put_file_stream"),
|
||||||
|
] {
|
||||||
|
assert!(!original.is_retryable_internode_write_failure());
|
||||||
|
let cloned = original.clone();
|
||||||
|
let reduced =
|
||||||
|
reduce_write_quorum_errs(&[Some(original)], &[], 1).expect("a non-retryable error must remain an error");
|
||||||
|
assert!(!cloned.is_retryable_internode_write_failure());
|
||||||
|
assert!(!reduced.is_retryable_internode_write_failure());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_count_errs() {
|
fn test_count_errs() {
|
||||||
let e1 = err_io("a");
|
let e1 = err_io("a");
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ fn restore_part_transaction_file(current: &Path, backup: &Path, absent: &Path, r
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
async fn write_metadata_rollback_backup(object_dir: &Path, rollback_dir: Uuid, data: &[u8]) -> Result<()> {
|
async fn write_metadata_rollback_backup(object_dir: &Path, rollback_dir: Uuid, data: &[u8]) -> Result<()> {
|
||||||
write_delete_rollback_file(object_dir, rollback_dir, STORAGE_FORMAT_FILE_BACKUP, data, None).await
|
write_delete_rollback_file(object_dir, rollback_dir, STORAGE_FORMAT_FILE_BACKUP, data, None).await
|
||||||
}
|
}
|
||||||
@@ -250,6 +251,7 @@ async fn write_delete_rollback_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
async fn restore_metadata_backup(
|
async fn restore_metadata_backup(
|
||||||
object_dir: &Path,
|
object_dir: &Path,
|
||||||
xl_path: &Path,
|
xl_path: &Path,
|
||||||
@@ -277,15 +279,6 @@ async fn restore_metadata_backup_with_namespace_owner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_delete_rollback(
|
|
||||||
object_dir: &Path,
|
|
||||||
xl_path: &Path,
|
|
||||||
rollback_dir: Uuid,
|
|
||||||
publication_root: &os::PublicationRoot,
|
|
||||||
) -> Result<()> {
|
|
||||||
restore_delete_rollback_with_namespace_owner(object_dir, xl_path, rollback_dir, publication_root, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore_delete_rollback_with_namespace_owner(
|
async fn restore_delete_rollback_with_namespace_owner(
|
||||||
object_dir: &Path,
|
object_dir: &Path,
|
||||||
xl_path: &Path,
|
xl_path: &Path,
|
||||||
@@ -5857,6 +5850,8 @@ impl LocalDisk {
|
|||||||
check_path_length(file_path.to_string_lossy().as_ref())?;
|
check_path_length(file_path.to_string_lossy().as_ref())?;
|
||||||
|
|
||||||
let xl_path = path_join(&[file_path.as_path(), Path::new(STORAGE_FORMAT_FILE)]);
|
let xl_path = path_join(&[file_path.as_path(), Path::new(STORAGE_FORMAT_FILE)]);
|
||||||
|
let namespace_owner: Option<Arc<dyn Send + Sync>> =
|
||||||
|
Some(os::acquire_metadata_mutation_lease(&self.get_object_path(volume, path)?, namespace_owner).await);
|
||||||
if opts.old_data_dir.is_some() && opts.undo_write {
|
if opts.old_data_dir.is_some() && opts.undo_write {
|
||||||
return self.undo_write(file_path.as_path(), &fi, &opts, namespace_owner).await;
|
return self.undo_write(file_path.as_path(), &fi, &opts, namespace_owner).await;
|
||||||
}
|
}
|
||||||
@@ -6714,6 +6709,8 @@ impl LocalDisk {
|
|||||||
|
|
||||||
async fn delete_versions_internal(&self, volume: &str, path: &str, fis: &[FileInfo], opts: &DeleteOptions) -> Result<()> {
|
async fn delete_versions_internal(&self, volume: &str, path: &str, fis: &[FileInfo], opts: &DeleteOptions) -> Result<()> {
|
||||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||||
|
let object_path = self.get_object_path(volume, path)?;
|
||||||
|
let namespace_owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&object_path, None).await);
|
||||||
let xlpath = self.io_get_object_path(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str())?;
|
let xlpath = self.io_get_object_path(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str())?;
|
||||||
let object_dir = xlpath
|
let object_dir = xlpath
|
||||||
.parent()
|
.parent()
|
||||||
@@ -6723,10 +6720,24 @@ impl LocalDisk {
|
|||||||
&& opts.undo_write
|
&& opts.undo_write
|
||||||
{
|
{
|
||||||
if opts.undo_delete {
|
if opts.undo_delete {
|
||||||
return restore_delete_rollback(object_dir, &xlpath, rollback_dir, &self.publication_root).await;
|
return restore_delete_rollback_with_namespace_owner(
|
||||||
|
object_dir,
|
||||||
|
&xlpath,
|
||||||
|
rollback_dir,
|
||||||
|
&self.publication_root,
|
||||||
|
namespace_owner.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
return restore_metadata_backup(object_dir, &xlpath, rollback_dir, &self.publication_root).await;
|
return restore_metadata_backup_with_namespace_owner(
|
||||||
|
object_dir,
|
||||||
|
&xlpath,
|
||||||
|
rollback_dir,
|
||||||
|
&self.publication_root,
|
||||||
|
namespace_owner.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (data, _) = match self.read_all_data_with_dmtime(volume, volume_dir.as_path(), &xlpath).await {
|
let (data, _) = match self.read_all_data_with_dmtime(volume, volume_dir.as_path(), &xlpath).await {
|
||||||
@@ -6738,7 +6749,14 @@ impl LocalDisk {
|
|||||||
return Err(DiskError::FileNotFound);
|
return Err(DiskError::FileNotFound);
|
||||||
};
|
};
|
||||||
return self
|
return self
|
||||||
.write_missing_delete_marker(volume, path, delete_marker, object_dir, opts.old_data_dir, None)
|
.write_missing_delete_marker(
|
||||||
|
volume,
|
||||||
|
path,
|
||||||
|
delete_marker,
|
||||||
|
object_dir,
|
||||||
|
opts.old_data_dir,
|
||||||
|
namespace_owner.clone(),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(err) => return Err(err),
|
Err(err) => return Err(err),
|
||||||
@@ -6754,7 +6772,8 @@ impl LocalDisk {
|
|||||||
let rollback_dir = opts.old_data_dir;
|
let rollback_dir = opts.old_data_dir;
|
||||||
let mut reserved_version_delete = false;
|
let mut reserved_version_delete = false;
|
||||||
if let Some(rollback_dir) = rollback_dir {
|
if let Some(rollback_dir) = rollback_dir {
|
||||||
write_metadata_rollback_backup(object_dir, rollback_dir, &data).await?;
|
write_delete_rollback_file(object_dir, rollback_dir, STORAGE_FORMAT_FILE_BACKUP, &data, namespace_owner.clone())
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for fi in fis.iter() {
|
for fi in fis.iter() {
|
||||||
@@ -6768,13 +6787,16 @@ impl LocalDisk {
|
|||||||
|
|
||||||
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(
|
.abort_reserved_version_delete_with_failure(
|
||||||
object_dir,
|
object_dir,
|
||||||
rollback_dir,
|
rollback_dir,
|
||||||
volume,
|
volume,
|
||||||
path,
|
path,
|
||||||
"delete_versions_metadata_update",
|
DeleteRollbackFailure {
|
||||||
err,
|
stage: "delete_versions_metadata_update",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
@@ -6787,7 +6809,7 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_metadata_update",
|
stage: "delete_versions_metadata_update",
|
||||||
error: err,
|
error: err,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
@@ -6804,13 +6826,16 @@ impl LocalDisk {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(
|
.abort_reserved_version_delete_with_failure(
|
||||||
object_dir,
|
object_dir,
|
||||||
rollback_dir,
|
rollback_dir,
|
||||||
volume,
|
volume,
|
||||||
path,
|
path,
|
||||||
"delete_versions_data_path",
|
DeleteRollbackFailure {
|
||||||
err,
|
stage: "delete_versions_data_path",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
@@ -6823,7 +6848,7 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_data_path",
|
stage: "delete_versions_data_path",
|
||||||
error: err,
|
error: err,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
@@ -6836,13 +6861,16 @@ impl LocalDisk {
|
|||||||
let err: DiskError = to_file_error(err).into();
|
let err: DiskError = to_file_error(err).into();
|
||||||
if reserved_version_delete {
|
if reserved_version_delete {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(
|
.abort_reserved_version_delete_with_failure(
|
||||||
object_dir,
|
object_dir,
|
||||||
rollback_dir,
|
rollback_dir,
|
||||||
volume,
|
volume,
|
||||||
path,
|
path,
|
||||||
"delete_versions_rollback_dir",
|
DeleteRollbackFailure {
|
||||||
err,
|
stage: "delete_versions_rollback_dir",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
@@ -6855,23 +6883,29 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_rollback_dir",
|
stage: "delete_versions_rollback_dir",
|
||||||
error: err,
|
error: err,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
let reserved = match self.reserve_version_delete(volume, path, dir, rollback_dir).await {
|
let reserved = match self
|
||||||
|
.reserve_version_delete_with_namespace_owner(volume, path, dir, rollback_dir, namespace_owner.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(reserved) => reserved,
|
Ok(reserved) => reserved,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(
|
.abort_reserved_version_delete_with_failure(
|
||||||
object_dir,
|
object_dir,
|
||||||
rollback_dir,
|
rollback_dir,
|
||||||
volume,
|
volume,
|
||||||
path,
|
path,
|
||||||
"delete_versions_reserve_data",
|
DeleteRollbackFailure {
|
||||||
err,
|
stage: "delete_versions_reserve_data",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
@@ -6879,11 +6913,12 @@ impl LocalDisk {
|
|||||||
reserved_version_delete |= reserved;
|
reserved_version_delete |= reserved;
|
||||||
let rollback_data_path = rollback_path.join(dir.to_string());
|
let rollback_data_path = rollback_path.join(dir.to_string());
|
||||||
if !reserved
|
if !reserved
|
||||||
&& let Err(err) = rename_all_ignore_missing_source(
|
&& let Err(err) = os::rename_all_ignore_missing_source_with_owner(
|
||||||
&dir_path,
|
&dir_path,
|
||||||
&rollback_data_path,
|
&rollback_data_path,
|
||||||
&rollback_path,
|
&rollback_path,
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
|
namespace_owner.clone(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -6896,7 +6931,7 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_stage_data",
|
stage: "delete_versions_stage_data",
|
||||||
error: err,
|
error: err,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
@@ -6905,13 +6940,16 @@ impl LocalDisk {
|
|||||||
if should_fail_after_delete_data_staged(path) {
|
if should_fail_after_delete_data_staged(path) {
|
||||||
if reserved_version_delete {
|
if reserved_version_delete {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(
|
.abort_reserved_version_delete_with_failure(
|
||||||
object_dir,
|
object_dir,
|
||||||
rollback_dir,
|
rollback_dir,
|
||||||
volume,
|
volume,
|
||||||
path,
|
path,
|
||||||
"delete_versions_test_after_stage",
|
DeleteRollbackFailure {
|
||||||
DiskError::Unexpected,
|
stage: "delete_versions_test_after_stage",
|
||||||
|
error: DiskError::Unexpected,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
@@ -6924,13 +6962,15 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_test_after_stage",
|
stage: "delete_versions_test_after_stage",
|
||||||
error: DiskError::Unexpected,
|
error: DiskError::Unexpected,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
} else if let Err(err) = self.move_to_trash(&dir_path, true, false).await
|
} else if let Err(err) = self
|
||||||
|
.move_to_trash_with_namespace_owner(&dir_path, true, false, namespace_owner.clone())
|
||||||
|
.await
|
||||||
&& !(err == DiskError::FileNotFound || err == DiskError::VolumeNotFound)
|
&& !(err == DiskError::FileNotFound || err == DiskError::VolumeNotFound)
|
||||||
{
|
{
|
||||||
return Err(err);
|
return Err(err);
|
||||||
@@ -6945,16 +6985,22 @@ impl LocalDisk {
|
|||||||
|
|
||||||
// Remove xl.meta when no versions remain
|
// Remove xl.meta when no versions remain
|
||||||
if fm.versions.is_empty() {
|
if fm.versions.is_empty() {
|
||||||
if let Err(err) = self.delete_file(&volume_dir, &xlpath, true, false).await {
|
if let Err(err) = self
|
||||||
|
.delete_file_with_namespace_owner(&volume_dir, &xlpath, true, false, namespace_owner.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(
|
.abort_reserved_version_delete_with_failure(
|
||||||
object_dir,
|
object_dir,
|
||||||
rollback_dir,
|
rollback_dir,
|
||||||
volume,
|
volume,
|
||||||
path,
|
path,
|
||||||
"delete_versions_commit_delete",
|
DeleteRollbackFailure {
|
||||||
err,
|
stage: "delete_versions_commit_delete",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
@@ -6967,7 +7013,7 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_commit_delete",
|
stage: "delete_versions_commit_delete",
|
||||||
error: err,
|
error: err,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
@@ -6975,10 +7021,22 @@ impl LocalDisk {
|
|||||||
}
|
}
|
||||||
if reserved_version_delete
|
if reserved_version_delete
|
||||||
&& let Some(rollback_dir) = rollback_dir
|
&& let Some(rollback_dir) = rollback_dir
|
||||||
&& let Err(err) = self.commit_reserved_version_delete(volume, path, rollback_dir).await
|
&& let Err(err) = self
|
||||||
|
.commit_reserved_version_delete_with_namespace_owner(volume, path, rollback_dir, namespace_owner.clone())
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(object_dir, rollback_dir, volume, path, "delete_versions_commit_intent", err)
|
.abort_reserved_version_delete_with_failure(
|
||||||
|
object_dir,
|
||||||
|
rollback_dir,
|
||||||
|
volume,
|
||||||
|
path,
|
||||||
|
DeleteRollbackFailure {
|
||||||
|
stage: "delete_versions_commit_intent",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
if should_fail_after_delete_commit(self.root.as_path(), path) {
|
if should_fail_after_delete_commit(self.root.as_path(), path) {
|
||||||
@@ -6995,13 +7053,16 @@ impl LocalDisk {
|
|||||||
let err: DiskError = err.into();
|
let err: DiskError = err.into();
|
||||||
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(
|
.abort_reserved_version_delete_with_failure(
|
||||||
object_dir,
|
object_dir,
|
||||||
rollback_dir,
|
rollback_dir,
|
||||||
volume,
|
volume,
|
||||||
path,
|
path,
|
||||||
"delete_versions_metadata_encode",
|
DeleteRollbackFailure {
|
||||||
err,
|
stage: "delete_versions_metadata_encode",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
@@ -7014,7 +7075,7 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_metadata_encode",
|
stage: "delete_versions_metadata_encode",
|
||||||
error: err,
|
error: err,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
@@ -7023,12 +7084,28 @@ impl LocalDisk {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = self
|
if let Err(err) = self
|
||||||
.write_all_meta(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str(), &buf, true)
|
.write_all_meta_with_namespace_owner(
|
||||||
|
volume,
|
||||||
|
format!("{path}/{STORAGE_FORMAT_FILE}").as_str(),
|
||||||
|
&buf,
|
||||||
|
true,
|
||||||
|
namespace_owner.clone(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
if reserved_version_delete && let Some(rollback_dir) = rollback_dir {
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(object_dir, rollback_dir, volume, path, "delete_versions_commit_write", err)
|
.abort_reserved_version_delete_with_failure(
|
||||||
|
object_dir,
|
||||||
|
rollback_dir,
|
||||||
|
volume,
|
||||||
|
path,
|
||||||
|
DeleteRollbackFailure {
|
||||||
|
stage: "delete_versions_commit_write",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
return Err(restore_delete_rollback_after_error(
|
return Err(restore_delete_rollback_after_error(
|
||||||
@@ -7040,7 +7117,7 @@ impl LocalDisk {
|
|||||||
DeleteRollbackFailure {
|
DeleteRollbackFailure {
|
||||||
stage: "delete_versions_commit_write",
|
stage: "delete_versions_commit_write",
|
||||||
error: err,
|
error: err,
|
||||||
namespace_owner: None,
|
namespace_owner: namespace_owner.clone(),
|
||||||
},
|
},
|
||||||
&self.publication_root,
|
&self.publication_root,
|
||||||
)
|
)
|
||||||
@@ -7049,10 +7126,22 @@ impl LocalDisk {
|
|||||||
|
|
||||||
if reserved_version_delete
|
if reserved_version_delete
|
||||||
&& let Some(rollback_dir) = rollback_dir
|
&& let Some(rollback_dir) = rollback_dir
|
||||||
&& let Err(err) = self.commit_reserved_version_delete(volume, path, rollback_dir).await
|
&& let Err(err) = self
|
||||||
|
.commit_reserved_version_delete_with_namespace_owner(volume, path, rollback_dir, namespace_owner.clone())
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
return Err(self
|
return Err(self
|
||||||
.abort_reserved_version_delete(object_dir, rollback_dir, volume, path, "delete_versions_commit_intent", err)
|
.abort_reserved_version_delete_with_failure(
|
||||||
|
object_dir,
|
||||||
|
rollback_dir,
|
||||||
|
volume,
|
||||||
|
path,
|
||||||
|
DeleteRollbackFailure {
|
||||||
|
stage: "delete_versions_commit_intent",
|
||||||
|
error: err,
|
||||||
|
namespace_owner: namespace_owner.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
.await);
|
.await);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7063,6 +7152,107 @@ impl LocalDisk {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reconcile_transition_state_metadata(
|
||||||
|
&self,
|
||||||
|
volume: &str,
|
||||||
|
object: &str,
|
||||||
|
version_id: Option<Uuid>,
|
||||||
|
condition: &super::TransitionStateReconcileCondition,
|
||||||
|
namespace_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
authority: Arc<crate::bucket::lifecycle::legacy_transition_state_reconcile::TransitionStateReconcileAuthority>,
|
||||||
|
) -> Result<()> {
|
||||||
|
condition.target.validate()?;
|
||||||
|
if !authority.is_current()
|
||||||
|
|| [
|
||||||
|
condition.expected_metadata_digest.as_str(),
|
||||||
|
condition.unchanged_metadata_digest.as_str(),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|digest| {
|
||||||
|
digest.len() != 64
|
||||||
|
|| !digest
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(DiskError::OutdatedXLMeta);
|
||||||
|
}
|
||||||
|
let metadata_path = format!("{object}/{STORAGE_FORMAT_FILE}");
|
||||||
|
let original = self.read_all(volume, &metadata_path).await?;
|
||||||
|
let original_digest = rustfs_utils::crypto::hex(<sha2::Sha256 as sha2::Digest>::digest(&original));
|
||||||
|
let mut metadata = FileMeta::load(&original)?;
|
||||||
|
let (_, selected) = metadata.find_version(version_id)?;
|
||||||
|
if selected.into_fileinfo(volume, object, true)?.transition_tier != condition.tier {
|
||||||
|
return Err(DiskError::OutdatedXLMeta);
|
||||||
|
}
|
||||||
|
let generation = metadata.transition_reconcile_generation(version_id)?;
|
||||||
|
if rustfs_utils::crypto::hex(<sha2::Sha256 as sha2::Digest>::digest(&generation)) != condition.unchanged_metadata_digest {
|
||||||
|
return Err(DiskError::OutdatedXLMeta);
|
||||||
|
}
|
||||||
|
let changed = metadata.reconcile_transition_state(version_id, &condition.target)?;
|
||||||
|
if !changed {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if condition.verify_only || original_digest != condition.expected_metadata_digest {
|
||||||
|
return Err(DiskError::OutdatedXLMeta);
|
||||||
|
}
|
||||||
|
// fsync_dir_std is a no-op outside Unix, so those platforms cannot
|
||||||
|
// yet prove this repair's durable publication requirement.
|
||||||
|
if !cfg!(unix) || !effective_durability(volume).syncs_commit_metadata() {
|
||||||
|
return Err(DiskError::other(
|
||||||
|
"transition reconciliation requires Unix directory sync and enabled bucket metadata durability",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// An outstanding rollback can still restore an older whole xl.meta.
|
||||||
|
// Its preparation and execution share this mutation domain; refuse
|
||||||
|
// repair until that transaction has settled and removed its backup.
|
||||||
|
let mut entries = fs::read_dir(self.io_get_object_path(volume, object)?)
|
||||||
|
.await
|
||||||
|
.map_err(to_file_error)?;
|
||||||
|
let mut remaining = 4096usize;
|
||||||
|
while let Some(entry) = entries.next_entry().await.map_err(to_file_error)? {
|
||||||
|
remaining = remaining.checked_sub(1).ok_or(DiskError::OutdatedXLMeta)?;
|
||||||
|
if Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for marker in [STORAGE_FORMAT_FILE_BACKUP, DELETE_MARKER_ROLLBACK_FILE] {
|
||||||
|
if fs::try_exists(entry.path().join(marker)).await.map_err(to_file_error)? {
|
||||||
|
return Err(DiskError::OutdatedXLMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let replacement = metadata.marshal_msg()?;
|
||||||
|
let tmp_volume = self.io_get_bucket_path(RUSTFS_META_TMP_BUCKET)?;
|
||||||
|
let tmp_file = self.io_get_object_path(RUSTFS_META_TMP_BUCKET, &Uuid::new_v4().to_string())?;
|
||||||
|
// Admission above requires metadata durability. Keep rename and its
|
||||||
|
// directory sync in the same owned executor even after cancellation.
|
||||||
|
self.write_all_internal(&tmp_file, InternalBuf::Ref(&replacement), SyncMode::FileOnly, &tmp_volume)
|
||||||
|
.await?;
|
||||||
|
if crash_inject::should_crash_at(CrashPoint::MetaWriteAfterTmpBeforeRename, &metadata_path) {
|
||||||
|
return Err(DiskError::Unexpected);
|
||||||
|
}
|
||||||
|
os::rename_reconciled_metadata(
|
||||||
|
tmp_file,
|
||||||
|
self.io_get_object_path(volume, &metadata_path)?,
|
||||||
|
self.io_get_bucket_path(volume)?,
|
||||||
|
self.publication_root.clone(),
|
||||||
|
namespace_owner.clone(),
|
||||||
|
authority,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Keep the same mutation lease through strong readback. Response loss
|
||||||
|
// leaves a monotonic subset for the coordinator's exact-copy retry.
|
||||||
|
let committed = self.read_all(volume, &metadata_path).await?;
|
||||||
|
let mut committed = FileMeta::load(&committed)?;
|
||||||
|
if committed.reconcile_transition_state(version_id, &condition.target)?
|
||||||
|
|| committed.transition_reconcile_generation(version_id)? != generation
|
||||||
|
{
|
||||||
|
return Err(DiskError::OutdatedXLMeta);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
async fn write_all_meta(&self, volume: &str, path: &str, buf: &[u8], sync: bool) -> Result<()> {
|
async fn write_all_meta(&self, volume: &str, path: &str, buf: &[u8], sync: bool) -> Result<()> {
|
||||||
self.write_all_meta_with_namespace_owner(volume, path, buf, sync, None).await
|
self.write_all_meta_with_namespace_owner(volume, path, buf, sync, None).await
|
||||||
}
|
}
|
||||||
@@ -8321,6 +8511,7 @@ impl LocalDisk {
|
|||||||
Ok(Arc::new(QuotaMutationFenceClaim { state }))
|
Ok(Arc::new(QuotaMutationFenceClaim { state }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
async fn reserve_version_delete(&self, volume: &str, object: &str, data_dir: Uuid, rollback_dir: Uuid) -> Result<bool> {
|
async fn reserve_version_delete(&self, volume: &str, object: &str, data_dir: Uuid, rollback_dir: Uuid) -> Result<bool> {
|
||||||
self.reserve_version_delete_with_namespace_owner(volume, object, data_dir, rollback_dir, None)
|
self.reserve_version_delete_with_namespace_owner(volume, object, data_dir, rollback_dir, None)
|
||||||
.await
|
.await
|
||||||
@@ -8373,6 +8564,7 @@ impl LocalDisk {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
async fn commit_reserved_version_delete(&self, volume: &str, object: &str, rollback_dir: Uuid) -> Result<()> {
|
async fn commit_reserved_version_delete(&self, volume: &str, object: &str, rollback_dir: Uuid) -> Result<()> {
|
||||||
self.commit_reserved_version_delete_with_namespace_owner(volume, object, rollback_dir, None)
|
self.commit_reserved_version_delete_with_namespace_owner(volume, object, rollback_dir, None)
|
||||||
.await
|
.await
|
||||||
@@ -8464,29 +8656,6 @@ impl LocalDisk {
|
|||||||
first_err.map_or(Ok(found), Err)
|
first_err.map_or(Ok(found), Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn abort_reserved_version_delete(
|
|
||||||
&self,
|
|
||||||
object_dir: &Path,
|
|
||||||
rollback_dir: Uuid,
|
|
||||||
volume: &str,
|
|
||||||
object: &str,
|
|
||||||
stage: &'static str,
|
|
||||||
err: DiskError,
|
|
||||||
) -> DiskError {
|
|
||||||
self.abort_reserved_version_delete_with_failure(
|
|
||||||
object_dir,
|
|
||||||
rollback_dir,
|
|
||||||
volume,
|
|
||||||
object,
|
|
||||||
DeleteRollbackFailure {
|
|
||||||
stage,
|
|
||||||
error: err,
|
|
||||||
namespace_owner: None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn abort_reserved_version_delete_with_failure(
|
async fn abort_reserved_version_delete_with_failure(
|
||||||
&self,
|
&self,
|
||||||
object_dir: &Path,
|
object_dir: &Path,
|
||||||
@@ -10031,6 +10200,23 @@ impl DiskAPI for LocalDisk {
|
|||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
async fn update_metadata(&self, volume: &str, path: &str, fi: FileInfo, opts: &UpdateMetadataOpts) -> Result<()> {
|
async fn update_metadata(&self, volume: &str, path: &str, fi: FileInfo, opts: &UpdateMetadataOpts) -> Result<()> {
|
||||||
|
let object_path = self.get_object_path(volume, path)?;
|
||||||
|
if let Some(condition) = &opts.transition_reconcile {
|
||||||
|
if !fi.metadata.is_empty() || opts.no_persistence || opts.replace_user_metadata {
|
||||||
|
return Err(DiskError::FileCorrupt);
|
||||||
|
}
|
||||||
|
let authority =
|
||||||
|
crate::bucket::lifecycle::legacy_transition_state_reconcile::TransitionStateReconcileAuthority::for_disk(
|
||||||
|
condition,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let owner: Option<Arc<dyn Send + Sync>> = Some(authority.clone());
|
||||||
|
let owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&object_path, owner).await);
|
||||||
|
return self
|
||||||
|
.reconcile_transition_state_metadata(volume, path, fi.version_id, condition, owner, authority)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let namespace_owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&object_path, None).await);
|
||||||
if !fi.metadata.is_empty() {
|
if !fi.metadata.is_empty() {
|
||||||
let file_path = self.io_get_object_path(volume, path)?;
|
let file_path = self.io_get_object_path(volume, path)?;
|
||||||
|
|
||||||
@@ -10058,7 +10244,13 @@ impl DiskAPI for LocalDisk {
|
|||||||
let wbuf = xl_meta.marshal_msg()?;
|
let wbuf = xl_meta.marshal_msg()?;
|
||||||
|
|
||||||
return self
|
return self
|
||||||
.write_all_meta(volume, format!("{path}/{STORAGE_FORMAT_FILE}").as_str(), &wbuf, !opts.no_persistence)
|
.write_all_meta_with_namespace_owner(
|
||||||
|
volume,
|
||||||
|
format!("{path}/{STORAGE_FORMAT_FILE}").as_str(),
|
||||||
|
&wbuf,
|
||||||
|
!opts.no_persistence,
|
||||||
|
namespace_owner,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10066,7 +10258,10 @@ impl DiskAPI for LocalDisk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
|
async fn write_metadata(&self, _org_volume: &str, volume: &str, path: &str, fi: FileInfo) -> Result<()> {
|
||||||
self.write_metadata_with_namespace_owner(volume, path, fi, None).await
|
let object_path = self.get_object_path(volume, path)?;
|
||||||
|
let namespace_owner: Option<Arc<dyn Send + Sync>> = Some(os::acquire_metadata_mutation_lease(&object_path, None).await);
|
||||||
|
self.write_metadata_with_namespace_owner(volume, path, fi, namespace_owner)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
@@ -22463,6 +22658,192 @@ mod test {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conditional_mrf_manifest_dir_fsync_failure_keeps_recovery_anchors() {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
|
||||||
|
const MRF_COMMIT_MANIFEST_SLOT_1: &str = ".heal-mrf-commit.1.bin";
|
||||||
|
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
|
||||||
|
|
||||||
|
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
|
||||||
|
let dir = tempdir().expect("temp dir should be created");
|
||||||
|
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||||
|
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||||
|
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
|
||||||
|
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
|
||||||
|
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
|
||||||
|
.await
|
||||||
|
.expect("previous MRF manifest should commit"),
|
||||||
|
ConditionalFileUpdate::Updated
|
||||||
|
);
|
||||||
|
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
|
||||||
|
.await
|
||||||
|
.expect("legacy MRF journal should be retained");
|
||||||
|
|
||||||
|
let manifest_path = disk
|
||||||
|
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||||
|
.expect("MRF manifest path should resolve");
|
||||||
|
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
|
||||||
|
assert!(
|
||||||
|
os::fsync_dir_recorder::was_fsynced(parent),
|
||||||
|
"system metadata MRF manifest publication must fsync the metadata directory even under relaxed durability"
|
||||||
|
);
|
||||||
|
os::fsync_dir_recorder::set_failure(parent, ErrorKind::Other);
|
||||||
|
|
||||||
|
let err = disk
|
||||||
|
.compare_and_update_file(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
MRF_COMMIT_MANIFEST_SLOT_0,
|
||||||
|
Some(previous_manifest.clone()),
|
||||||
|
Some(successor_manifest),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("directory fsync failure must fail the MRF manifest successor commit");
|
||||||
|
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::Other));
|
||||||
|
assert_eq!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||||
|
.await
|
||||||
|
.expect("previous committed MRF manifest should remain readable after rollback"),
|
||||||
|
previous_manifest
|
||||||
|
);
|
||||||
|
|
||||||
|
os::fsync_dir_recorder::set_failure(parent, ErrorKind::Other);
|
||||||
|
let err = disk
|
||||||
|
.compare_and_update_file(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
MRF_COMMIT_MANIFEST_SLOT_1,
|
||||||
|
None,
|
||||||
|
Some(Bytes::from_static(b"first-successor-manifest")),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("directory fsync failure must fail first MRF manifest commit");
|
||||||
|
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::Other));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_1).await,
|
||||||
|
Err(DiskError::FileNotFound)
|
||||||
|
),
|
||||||
|
"uncommitted first MRF manifest must be removed when no committed anchor exists"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||||
|
.await
|
||||||
|
.expect("legacy MRF journal should remain readable after failed manifest publication"),
|
||||||
|
legacy_journal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conditional_mrf_manifest_storage_full_keeps_recovery_anchors() {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
|
||||||
|
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
|
||||||
|
|
||||||
|
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
|
||||||
|
let dir = tempdir().expect("temp dir should be created");
|
||||||
|
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||||
|
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||||
|
let previous_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
|
||||||
|
let successor_manifest = Bytes::from_static(b"mrf-committed-manifest-v2");
|
||||||
|
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(previous_manifest.clone()),)
|
||||||
|
.await
|
||||||
|
.expect("previous MRF manifest should commit"),
|
||||||
|
ConditionalFileUpdate::Updated
|
||||||
|
);
|
||||||
|
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
|
||||||
|
.await
|
||||||
|
.expect("legacy MRF journal should be retained");
|
||||||
|
|
||||||
|
let manifest_path = disk
|
||||||
|
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||||
|
.expect("MRF manifest path should resolve");
|
||||||
|
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
|
||||||
|
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
|
||||||
|
|
||||||
|
let err = disk
|
||||||
|
.compare_and_update_file(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
MRF_COMMIT_MANIFEST_SLOT_0,
|
||||||
|
Some(previous_manifest.clone()),
|
||||||
|
Some(successor_manifest),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("storage-full fsync failure must fail the MRF manifest successor commit");
|
||||||
|
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
|
||||||
|
assert_eq!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||||
|
.await
|
||||||
|
.expect("previous committed MRF manifest should remain readable after storage-full rollback"),
|
||||||
|
previous_manifest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||||
|
.await
|
||||||
|
.expect("legacy MRF journal should remain readable after storage-full manifest publication failure"),
|
||||||
|
legacy_journal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conditional_mrf_manifest_storage_full_delete_keeps_recovery_anchors() {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
const MRF_COMMIT_MANIFEST_SLOT_0: &str = ".heal-mrf-commit.0.bin";
|
||||||
|
const MRF_SCOPED_JOURNAL_PATH: &str = "buckets/.heal/mrf/journal-scoped.bin";
|
||||||
|
|
||||||
|
let _mode = durability_mode_override::set(DurabilityMode::Relaxed);
|
||||||
|
let dir = tempdir().expect("temp dir should be created");
|
||||||
|
let endpoint = Endpoint::try_from(dir.path().to_str().expect("temp dir should be utf8")).expect("endpoint should parse");
|
||||||
|
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
||||||
|
let committed_manifest = Bytes::from_static(b"mrf-committed-manifest-v1");
|
||||||
|
let legacy_journal = Bytes::from_static(b"legacy-mrf-journal-records");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
disk.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, None, Some(committed_manifest.clone()),)
|
||||||
|
.await
|
||||||
|
.expect("committed MRF manifest should publish"),
|
||||||
|
ConditionalFileUpdate::Updated
|
||||||
|
);
|
||||||
|
disk.write_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH, legacy_journal.clone())
|
||||||
|
.await
|
||||||
|
.expect("legacy MRF journal should be retained");
|
||||||
|
|
||||||
|
let manifest_path = disk
|
||||||
|
.get_object_path(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||||
|
.expect("MRF manifest path should resolve");
|
||||||
|
let parent = manifest_path.parent().expect("MRF manifest path should have a parent");
|
||||||
|
os::fsync_dir_recorder::set_failure(parent, ErrorKind::StorageFull);
|
||||||
|
|
||||||
|
let err = disk
|
||||||
|
.compare_and_update_file(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0, Some(committed_manifest.clone()), None)
|
||||||
|
.await
|
||||||
|
.expect_err("storage-full fsync failure must fail the MRF manifest cleanup delete");
|
||||||
|
assert!(matches!(err, DiskError::Io(ref err) if err.kind() == ErrorKind::StorageFull));
|
||||||
|
assert_eq!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, MRF_COMMIT_MANIFEST_SLOT_0)
|
||||||
|
.await
|
||||||
|
.expect("committed MRF manifest should be restored after failed cleanup delete"),
|
||||||
|
committed_manifest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, MRF_SCOPED_JOURNAL_PATH)
|
||||||
|
.await
|
||||||
|
.expect("legacy MRF journal should remain readable after failed cleanup delete"),
|
||||||
|
legacy_journal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
|
async fn conditional_file_update_dir_fsync_failure_removes_new_file_without_anchor() {
|
||||||
|
|||||||
@@ -279,11 +279,14 @@ impl LocalDisk {
|
|||||||
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
|
Some(token) => Some(self.claim_quota_mutation_fence(dst_volume, dst_path, token).await?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
// Quota admission -> metadata RMW -> namespace/volume publication.
|
||||||
|
let metadata_lease =
|
||||||
|
os::acquire_metadata_mutation_lease(&self.get_object_path(dst_volume, dst_path)?, state.namespace_owner.take()).await;
|
||||||
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
|
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
|
||||||
&self.root,
|
&self.root,
|
||||||
dst_volume,
|
dst_volume,
|
||||||
&destination_object_path,
|
&destination_object_path,
|
||||||
state.namespace_owner.take(),
|
Some(metadata_lease),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Some(claim) = quota_fence_claim {
|
if let Some(claim) = quota_fence_claim {
|
||||||
|
|||||||
@@ -1277,6 +1277,25 @@ pub struct CheckPartsResp {
|
|||||||
pub struct UpdateMetadataOpts {
|
pub struct UpdateMetadataOpts {
|
||||||
pub no_persistence: bool,
|
pub no_persistence: bool,
|
||||||
pub replace_user_metadata: bool,
|
pub replace_user_metadata: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transition_reconcile: Option<Box<TransitionStateReconcileCondition>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An exact-copy precondition for the single-version tier repair protocol.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TransitionStateReconcileCondition {
|
||||||
|
pub expected_metadata_digest: String,
|
||||||
|
pub unchanged_metadata_digest: String,
|
||||||
|
pub target: rustfs_filemeta::TransitionStateReconcileTarget,
|
||||||
|
pub tier: String,
|
||||||
|
pub topology_generation: String,
|
||||||
|
pub verify_only: bool,
|
||||||
|
/// Local ownership is never accepted from the wire. A remote disk acquires
|
||||||
|
/// its own fleet and backend leases before entering the mutation domain.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub(crate) authority:
|
||||||
|
Option<Arc<crate::bucket::lifecycle::legacy_transition_state_reconcile::TransitionStateReconcileAuthority>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DiskLocation {
|
pub struct DiskLocation {
|
||||||
|
|||||||
+195
-38
@@ -452,13 +452,13 @@ pub(crate) mod windows_rename_test_hooks {
|
|||||||
|
|
||||||
/// Test-only hooks into the destination-parent walk of rename preparation.
|
/// Test-only hooks into the destination-parent walk of rename preparation.
|
||||||
///
|
///
|
||||||
/// The prune race lives between two syscalls inside
|
/// Pruning and Windows sharing races live between syscalls inside
|
||||||
/// [`mkdir_all_below_existing_base_std`], so only an injection at that exact
|
/// [`mkdir_all_below_existing_base_std`], so only an injection at that exact
|
||||||
/// point reproduces it deterministically. Hooks are keyed by the absolute path
|
/// point reproduces it deterministically. Hooks are keyed by the absolute path
|
||||||
/// of the component just opened and queued per path: a retrying preparation
|
/// of the component just opened and queued per path: a retrying preparation
|
||||||
/// visits the same component again, so a test models a pruner that keeps
|
/// visits the same component again, so a test models a pruner that keeps
|
||||||
/// walking upward by queueing one hook per visit.
|
/// walking upward by queueing one hook per visit.
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, any(unix, windows)))]
|
||||||
pub(crate) mod prepare_rename_test_hooks {
|
pub(crate) mod prepare_rename_test_hooks {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -1449,6 +1449,38 @@ fn disk_namespace_mutation_lock(path: &Path) -> Arc<NamespaceMutationLock> {
|
|||||||
lock
|
lock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static DISK_METADATA_MUTATION_LOCKS: LazyLock<Mutex<NamespaceMutationLockRegistry>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// Serializes the complete xl.meta read/modify/commit transaction. This domain
|
||||||
|
/// precedes namespace/volume publication locks, whose narrower syscall leases
|
||||||
|
/// may retain it after cancellation of the async caller.
|
||||||
|
pub(crate) struct MetadataMutationLease {
|
||||||
|
_guard: OwnedMutexGuard<()>,
|
||||||
|
_owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_metadata_mutation_lease(
|
||||||
|
object: &Path,
|
||||||
|
owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
) -> Arc<MetadataMutationLease> {
|
||||||
|
let lock = {
|
||||||
|
let mut locks = DISK_METADATA_MUTATION_LOCKS.lock();
|
||||||
|
locks.retain(|_, lock| lock.strong_count() > 0);
|
||||||
|
if let Some(lock) = locks.get(object).and_then(Weak::upgrade) {
|
||||||
|
lock
|
||||||
|
} else {
|
||||||
|
let lock = Arc::new(AsyncMutex::new(()));
|
||||||
|
locks.insert(object.to_path_buf(), Arc::downgrade(&lock));
|
||||||
|
lock
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Arc::new(MetadataMutationLease {
|
||||||
|
_guard: lock.lock_owned().await,
|
||||||
|
_owner: owner,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Keeps a namespace transaction serialized even when its async waiter is
|
/// Keeps a namespace transaction serialized even when its async waiter is
|
||||||
/// cancelled while a blocking filesystem call is still running.
|
/// cancelled while a blocking filesystem call is still running.
|
||||||
pub(crate) struct NamespaceMutationLease {
|
pub(crate) struct NamespaceMutationLease {
|
||||||
@@ -2112,6 +2144,41 @@ pub(crate) async fn rename_all_with_lease(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish a conditional repair and sync its directory in one owned executor.
|
||||||
|
/// Cancellation cannot release its metadata/fleet/tier leases between rename
|
||||||
|
/// and fsync. The last authority check runs after destination preparation.
|
||||||
|
pub(crate) async fn rename_reconciled_metadata(
|
||||||
|
source: PathBuf,
|
||||||
|
destination: PathBuf,
|
||||||
|
base_dir: PathBuf,
|
||||||
|
publication_root: PublicationRoot,
|
||||||
|
owner: Option<Arc<dyn Send + Sync>>,
|
||||||
|
authority: Arc<crate::bucket::lifecycle::legacy_transition_state_reconcile::TransitionStateReconcileAuthority>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let lease = acquire_namespace_mutation_lease_with_owner(&destination, owner).await;
|
||||||
|
run_blocking_namespace_operation(lease, move || {
|
||||||
|
let preparation = prepare_rename_with_retry(&source, &destination, &base_dir, &publication_root)?;
|
||||||
|
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
|
||||||
|
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &destination);
|
||||||
|
if !authority.is_current() {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::WouldBlock, "transition reconciliation authority expired"));
|
||||||
|
}
|
||||||
|
rename_prepared(&source, &destination, &preparation)?;
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
fsync_dir_std(parent)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
if err.kind() == io::ErrorKind::WouldBlock {
|
||||||
|
DiskError::OutdatedXLMeta
|
||||||
|
} else {
|
||||||
|
to_file_error(err).into()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[tracing::instrument(level = "debug", skip_all)]
|
#[tracing::instrument(level = "debug", skip_all)]
|
||||||
pub(crate) async fn rename_all_with_commit_guard(
|
pub(crate) async fn rename_all_with_commit_guard(
|
||||||
@@ -4208,11 +4275,14 @@ pub(crate) fn mkdir_all_below_existing_base_std(
|
|||||||
let mut handles = Vec::with_capacity(capacity);
|
let mut handles = Vec::with_capacity(capacity);
|
||||||
handles.push(publication_root.directory.clone());
|
handles.push(publication_root.directory.clone());
|
||||||
let mut guard = ExistingBaseDirectoryGuard::new(handles);
|
let mut guard = ExistingBaseDirectoryGuard::new(handles);
|
||||||
for component in base_relative.components() {
|
let mut components = base_relative
|
||||||
|
.components()
|
||||||
|
.map(|component| (component, FILE_OPEN))
|
||||||
|
.chain(relative.components().map(|component| (component, FILE_OPEN_IF)))
|
||||||
|
.filter(|(component, _)| !matches!(component, Component::CurDir))
|
||||||
|
.peekable();
|
||||||
|
while let Some((component, disposition)) = components.next() {
|
||||||
let Component::Normal(component) = component else {
|
let Component::Normal(component) = component else {
|
||||||
if matches!(component, Component::CurDir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Err(io::Error::new(
|
return Err(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
io::ErrorKind::InvalidInput,
|
||||||
"rename base directory contains an invalid path component",
|
"rename base directory contains an invalid path component",
|
||||||
@@ -4222,40 +4292,22 @@ pub(crate) fn mkdir_all_below_existing_base_std(
|
|||||||
.handles
|
.handles
|
||||||
.last()
|
.last()
|
||||||
.ok_or_else(|| io::Error::other("Windows publication root guard is empty"))?;
|
.ok_or_else(|| io::Error::other("Windows publication root guard is empty"))?;
|
||||||
let child = open_windows_directory_component(parent, component, FILE_OPEN)?;
|
// The kernel opens the final parent for write during a relative
|
||||||
guard.handles.push(child);
|
// rename. Share writes from its first open: a temporary read-only
|
||||||
}
|
// share would block another rename into the same trash directory.
|
||||||
for component in relative.components() {
|
// Ancestors stay strict and no handle shares delete access, keeping
|
||||||
let Component::Normal(component) = component else {
|
// every retained directory identity pinned.
|
||||||
continue;
|
let share_access = if components.peek().is_none() {
|
||||||
|
FILE_SHARE_READ | FILE_SHARE_WRITE
|
||||||
|
} else {
|
||||||
|
FILE_SHARE_READ
|
||||||
};
|
};
|
||||||
let parent = guard
|
let child = open_windows_relative_directory_component(parent, component, disposition, share_access)?;
|
||||||
.handles
|
|
||||||
.last()
|
|
||||||
.ok_or_else(|| io::Error::other("Windows base directory guard is empty"))?;
|
|
||||||
let child = open_windows_directory_component(parent, component, FILE_OPEN_IF)?;
|
|
||||||
guard.handles.push(child);
|
guard.handles.push(child);
|
||||||
}
|
#[cfg(test)]
|
||||||
|
if components.peek().is_none() {
|
||||||
// Windows resolves a handle-relative rename by opening the target for
|
prepare_rename_test_hooks::run_after_component_opened(dir_path);
|
||||||
// write. Keep every ancestor strict, but let that internal open share
|
}
|
||||||
// the final parent. Delete sharing remains omitted, so the retained
|
|
||||||
// directory entry cannot be renamed or removed during publication.
|
|
||||||
if guard.handles.len() > 1 {
|
|
||||||
let component = dir_path
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination parent must have a name"))?;
|
|
||||||
let parent_index = guard.handles.len() - 2;
|
|
||||||
let parent = guard
|
|
||||||
.handles
|
|
||||||
.get(parent_index)
|
|
||||||
.ok_or_else(|| io::Error::other("Windows destination guard lost its parent handle"))?;
|
|
||||||
let rename_parent =
|
|
||||||
open_windows_relative_directory_component(parent, component, FILE_OPEN, FILE_SHARE_READ | FILE_SHARE_WRITE)?;
|
|
||||||
*guard
|
|
||||||
.handles
|
|
||||||
.last_mut()
|
|
||||||
.ok_or_else(|| io::Error::other("Windows destination guard is empty"))? = rename_parent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(guard)
|
Ok(guard)
|
||||||
@@ -5446,6 +5498,111 @@ mod tests {
|
|||||||
assert_eq!(std::fs::read(dst).expect("read committed metadata"), b"metadata");
|
assert_eq!(std::fs::read(dst).expect("read committed metadata"), b"metadata");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn rename_all_concurrent_trash_renames_remove_every_rollback_directory() {
|
||||||
|
let temp_dir = tempdir().expect("create temp dir");
|
||||||
|
let trash = temp_dir.path().join(".rustfs.sys/tmp/.trash");
|
||||||
|
std::fs::create_dir_all(&trash).expect("create trash directory");
|
||||||
|
let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root");
|
||||||
|
let sources: Vec<_> = (0..16)
|
||||||
|
.map(|index| {
|
||||||
|
let source = temp_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("bucket/{index}.mp4"))
|
||||||
|
.join(uuid::Uuid::new_v4().to_string());
|
||||||
|
std::fs::create_dir_all(&source).expect("create rollback directory");
|
||||||
|
std::fs::write(source.join("xl.meta.bkp"), b"rollback metadata").expect("write metadata backup");
|
||||||
|
source
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = futures::future::join_all(sources.iter().enumerate().map(|(index, source)| {
|
||||||
|
super::rename_all_ignore_missing_source(source, trash.join(index.to_string()), &trash, &publication_root)
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for (index, (source, result)) in sources.iter().zip(results).enumerate() {
|
||||||
|
result.expect("concurrent rollback cleanup must reach the shared trash directory");
|
||||||
|
assert!(!source.exists(), "rollback cleanup must not leave a directory in the bucket");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(trash.join(index.to_string()).join("xl.meta.bkp")).expect("read moved metadata backup"),
|
||||||
|
b"rollback metadata",
|
||||||
|
"trash staging must retain the complete backup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_trash_rename_succeeds_during_concurrent_parent_preparation() {
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{ERROR_SHARING_VIOLATION, GENERIC_WRITE},
|
||||||
|
Storage::FileSystem::{
|
||||||
|
DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
|
||||||
|
FILE_SHARE_WRITE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exercise the final parent both in the existing base walk and in the
|
||||||
|
// creatable suffix walk. Neither may briefly deny write sharing.
|
||||||
|
for nested_parent in [false, true] {
|
||||||
|
let temp_dir = tempdir().expect("create temp dir");
|
||||||
|
let tmp = temp_dir.path().join(".rustfs.sys/tmp");
|
||||||
|
let trash = tmp.join(".trash");
|
||||||
|
std::fs::create_dir_all(&trash).expect("create trash directory");
|
||||||
|
let base = if nested_parent { &tmp } else { &trash };
|
||||||
|
let publication_root = PublicationRoot::new(temp_dir.path()).expect("open publication root");
|
||||||
|
let sources = ["first", "second"].map(|name| {
|
||||||
|
let source = temp_dir
|
||||||
|
.path()
|
||||||
|
.join("bucket")
|
||||||
|
.join(name)
|
||||||
|
.join(uuid::Uuid::new_v4().to_string());
|
||||||
|
std::fs::create_dir_all(&source).expect("create rollback directory");
|
||||||
|
std::fs::write(source.join("xl.meta.bkp"), name.as_bytes()).expect("write metadata backup");
|
||||||
|
source
|
||||||
|
});
|
||||||
|
let destinations = [trash.join("first"), trash.join("second")];
|
||||||
|
let first_preparation = prepare_rename_with_retry(&sources[0], &destinations[0], base, &publication_root)
|
||||||
|
.expect("prepare the first trash rename");
|
||||||
|
let first_source = sources[0].clone();
|
||||||
|
let first_destination = destinations[0].clone();
|
||||||
|
let interleaved = Arc::new(AtomicBool::new(false));
|
||||||
|
let interleaved_hook = Arc::clone(&interleaved);
|
||||||
|
prepare_rename_test_hooks::queue_after_component_opened(&trash, move || {
|
||||||
|
interleaved_hook.store(true, Ordering::Release);
|
||||||
|
rename_prepared(&first_source, &first_destination, &first_preparation)
|
||||||
|
.expect("another preparation's first parent handle must allow the pending trash rename");
|
||||||
|
});
|
||||||
|
|
||||||
|
let second_preparation = prepare_rename_with_retry(&sources[1], &destinations[1], base, &publication_root)
|
||||||
|
.expect("prepare the second trash rename");
|
||||||
|
assert!(interleaved.load(Ordering::Acquire), "the competing parent-open window must be exercised");
|
||||||
|
for (path, access) in [(&tmp, GENERIC_WRITE), (&trash, DELETE)] {
|
||||||
|
let err = std::fs::OpenOptions::new()
|
||||||
|
.access_mode(access)
|
||||||
|
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||||
|
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||||||
|
.open(path)
|
||||||
|
.expect_err("ancestor writes and final-parent deletion must remain excluded");
|
||||||
|
assert_eq!(
|
||||||
|
err.raw_os_error(),
|
||||||
|
Some(i32::try_from(ERROR_SHARING_VIOLATION).expect("Windows error code must fit i32"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rename_prepared(&sources[1], &destinations[1], &second_preparation).expect("publish the second trash rename");
|
||||||
|
for (index, payload) in [b"first".as_slice(), b"second".as_slice()].into_iter().enumerate() {
|
||||||
|
assert!(!sources[index].exists(), "both rollback directories must leave the bucket");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(destinations[index].join("xl.meta.bkp")).expect("read the staged backup"),
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn windows_rename_all_supports_same_parent_publication() {
|
async fn windows_rename_all_supports_same_parent_publication() {
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ impl<'a> MultiWriter<'a> {
|
|||||||
errs = ?self.errs,
|
errs = ?self.errs,
|
||||||
"Erasure encode write quorum unavailable: {summary_text}"
|
"Erasure encode write quorum unavailable: {summary_text}"
|
||||||
);
|
);
|
||||||
Err(std::io::Error::other(format!("Failed to write data: {summary_text}")))
|
Err(write_err.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
|
async fn shutdown_writer(writer_opt: &mut Option<BitrotWriterWrapper>, err: &mut Option<Error>) {
|
||||||
@@ -503,7 +503,7 @@ impl<'a> MultiWriter<'a> {
|
|||||||
errs = ?self.errs,
|
errs = ?self.errs,
|
||||||
"Erasure encode shutdown quorum unavailable: {summary_text}"
|
"Erasure encode shutdown quorum unavailable: {summary_text}"
|
||||||
);
|
);
|
||||||
Err(std::io::Error::other(format!("Failed to shutdown writers: {summary_text}")))
|
Err(write_err.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,6 +1002,7 @@ impl Erasure {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
|
use crate::erasure::coding::{BitrotWriterWrapper, CustomWriter};
|
||||||
|
use crate::error::StorageError;
|
||||||
use rustfs_rio::HardLimitReader;
|
use rustfs_rio::HardLimitReader;
|
||||||
use rustfs_utils::HashAlgorithm;
|
use rustfs_utils::HashAlgorithm;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
@@ -1451,7 +1452,14 @@ mod tests {
|
|||||||
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
|
Ok(_) => panic!("writer quorum failure should fail the encode pipeline"),
|
||||||
Err(err) => err,
|
Err(err) => err,
|
||||||
};
|
};
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
let err = StorageError::from(err);
|
||||||
|
assert!(matches!(
|
||||||
|
&err,
|
||||||
|
StorageError::Io(source)
|
||||||
|
if source.kind() == std::io::ErrorKind::Other
|
||||||
|
&& source.to_string() == "injected write failure after producer blocks"
|
||||||
|
));
|
||||||
|
assert!(!err.is_quorum_error());
|
||||||
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
tokio::time::timeout(Duration::from_secs(1), reader_dropped)
|
||||||
.await
|
.await
|
||||||
.expect("writer failure should abort the blocked producer")
|
.expect("writer failure should abort the blocked producer")
|
||||||
@@ -1644,7 +1652,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn multi_writer_short_write_fails_before_shutdown() {
|
async fn multi_writer_short_write_fails_before_shutdown() {
|
||||||
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 16))];
|
let mut writers = vec![Some(bitrot_writer(ShortWriteWriter, 32))];
|
||||||
let err = {
|
let err = {
|
||||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||||
writer
|
writer
|
||||||
@@ -1653,63 +1661,93 @@ mod tests {
|
|||||||
.expect_err("short writes must fail the shard writer")
|
.expect_err("short writes must fail the shard writer")
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
let err = StorageError::from(err);
|
||||||
|
assert!(matches!(&err, StorageError::Io(source) if source.kind() == std::io::ErrorKind::WriteZero));
|
||||||
|
assert!(!err.is_quorum_error());
|
||||||
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
|
assert!(writers[0].is_none(), "short-write shard must be removed before commit");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
|
async fn multi_writer_reports_fallback_summary_when_only_offline_writers_remain() {
|
||||||
let mut writers = vec![None, None];
|
let mut writers = vec![None, None];
|
||||||
let err = {
|
let (err, summary) = {
|
||||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||||
writer
|
let err = writer
|
||||||
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
|
.write(vec![Bytes::from_static(b"offline-a"), Bytes::from_static(b"offline-b")])
|
||||||
.await
|
.await
|
||||||
.expect_err("offline writers cannot satisfy write quorum")
|
.expect_err("offline writers cannot satisfy write quorum");
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
(err, format_write_quorum_failure(&summary))
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = err.to_string();
|
assert_eq!(
|
||||||
assert!(err.contains("Failed to write data"));
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(err.contains("offline-disks=2/2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(err.contains("required=1"));
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
|
assert!(summary.contains("offline-disks=2/2"));
|
||||||
|
assert!(summary.contains("required=1"));
|
||||||
|
|
||||||
let shutdown_err = {
|
let (shutdown_err, summary) = {
|
||||||
let mut writer = MultiWriter::new(&mut writers, 1);
|
let mut writer = MultiWriter::new(&mut writers, 1);
|
||||||
writer
|
let err = writer
|
||||||
.shutdown()
|
.shutdown()
|
||||||
.await
|
.await
|
||||||
.expect_err("offline writers cannot satisfy shutdown quorum")
|
.expect_err("offline writers cannot satisfy shutdown quorum");
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
(err, format_write_quorum_failure(&summary))
|
||||||
};
|
};
|
||||||
|
|
||||||
let shutdown_err = shutdown_err.to_string();
|
assert_eq!(
|
||||||
assert!(shutdown_err.contains("Failed to shutdown writers"));
|
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(shutdown_err.contains("offline-disks=2/2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(shutdown_err.contains("required=1"));
|
);
|
||||||
|
let shutdown_err = StorageError::from(shutdown_err);
|
||||||
|
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(shutdown_err.is_quorum_error());
|
||||||
|
assert!(summary.contains("offline-disks=2/2"));
|
||||||
|
assert!(summary.contains("required=1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
|
async fn multi_writer_reports_quorum_failure_when_quorum_exceeds_writer_count() {
|
||||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||||
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 16))];
|
let mut writers = vec![Some(bitrot_writer(DeferredCommitWriter::new(committed), 32))];
|
||||||
let mut writer = MultiWriter::new(&mut writers, 2);
|
let mut writer = MultiWriter::new(&mut writers, 2);
|
||||||
|
|
||||||
let err = writer
|
let err = writer
|
||||||
.write(vec![Bytes::from_static(b"quorum impossible")])
|
.write(vec![Bytes::from_static(b"quorum impossible")])
|
||||||
.await
|
.await
|
||||||
.expect_err("write quorum above writer count must fail");
|
.expect_err("write quorum above writer count must fail");
|
||||||
let err = err.to_string();
|
assert_eq!(
|
||||||
assert!(err.contains("Failed to write data"));
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(err.contains("required=2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(err.contains("erasure write quorum"));
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
let summary = format_write_quorum_failure(&summary);
|
||||||
|
assert!(summary.contains("required=2"));
|
||||||
|
assert!(summary.contains("erasure write quorum"));
|
||||||
|
|
||||||
let shutdown_err = writer
|
let shutdown_err = writer
|
||||||
.shutdown()
|
.shutdown()
|
||||||
.await
|
.await
|
||||||
.expect_err("shutdown quorum above writer count must fail");
|
.expect_err("shutdown quorum above writer count must fail");
|
||||||
let shutdown_err = shutdown_err.to_string();
|
assert_eq!(
|
||||||
assert!(shutdown_err.contains("Failed to shutdown writers"));
|
shutdown_err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
assert!(shutdown_err.contains("required=2"));
|
Some(&Error::ErasureWriteQuorum),
|
||||||
assert!(shutdown_err.contains("erasure write quorum"));
|
);
|
||||||
|
let shutdown_err = StorageError::from(shutdown_err);
|
||||||
|
assert_eq!(shutdown_err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(shutdown_err.is_quorum_error());
|
||||||
|
let summary = build_write_quorum_failure_summary(&writer.errs, OBJECT_OP_IGNORED_ERRS, writer.write_quorum);
|
||||||
|
let summary = format_write_quorum_failure(&summary);
|
||||||
|
assert!(summary.contains("required=2"));
|
||||||
|
assert!(summary.contains("erasure write quorum"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The production wiring (`MultiWriter::new`) must arm a real deadline by
|
// The production wiring (`MultiWriter::new`) must arm a real deadline by
|
||||||
@@ -1794,7 +1832,13 @@ mod tests {
|
|||||||
.write(four_shards())
|
.write(four_shards())
|
||||||
.await
|
.await
|
||||||
.expect_err("two stalled writers must fail the write quorum instead of hanging");
|
.expect_err("two stalled writers must fail the write quorum instead of hanging");
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
// A small object whose bytes were fully buffered leaves `write` succeeding
|
// A small object whose bytes were fully buffered leaves `write` succeeding
|
||||||
@@ -1839,7 +1883,13 @@ mod tests {
|
|||||||
.shutdown()
|
.shutdown()
|
||||||
.await
|
.await
|
||||||
.expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging");
|
.expect_err("two shutdown stalls must fail the shutdown quorum instead of hanging");
|
||||||
assert!(err.to_string().contains("Failed to shutdown writers"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
// A slow-but-honest writer that keeps completing shards (delay < stall
|
// A slow-but-honest writer that keeps completing shards (delay < stall
|
||||||
@@ -2121,7 +2171,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("streaming encode must fail when write quorum is unavailable");
|
.expect_err("streaming encode must fail when write quorum is unavailable");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2145,7 +2201,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("write quorum failure must fail the inline encode");
|
.expect_err("write quorum failure must fail the inline encode");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
assert!(
|
assert!(
|
||||||
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||||
"successful writer must not be committed when write quorum fails before shutdown"
|
"successful writer must not be committed when write quorum fails before shutdown"
|
||||||
@@ -2173,7 +2235,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("shutdown quorum failure must fail the inline encode");
|
.expect_err("shutdown quorum failure must fail the inline encode");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to shutdown writers"));
|
let err = StorageError::from(err);
|
||||||
|
assert!(matches!(
|
||||||
|
&err,
|
||||||
|
StorageError::Io(source)
|
||||||
|
if source.kind() == std::io::ErrorKind::Other && source.to_string() == "injected shutdown failure"
|
||||||
|
));
|
||||||
|
assert!(!err.is_quorum_error());
|
||||||
assert!(
|
assert!(
|
||||||
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
!committed.lock().expect("committed buffer should be lockable").is_empty(),
|
||||||
"the successful writer should have committed before shutdown quorum failure was reported"
|
"the successful writer should have committed before shutdown quorum failure was reported"
|
||||||
@@ -2395,7 +2463,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect_err("batched encode must fail when write quorum is unavailable");
|
.expect_err("batched encode must fail when write quorum is unavailable");
|
||||||
|
|
||||||
assert!(err.to_string().contains("Failed to write data"));
|
assert_eq!(
|
||||||
|
err.get_ref().and_then(|source| source.downcast_ref::<Error>()),
|
||||||
|
Some(&Error::ErasureWriteQuorum),
|
||||||
|
);
|
||||||
|
let err = StorageError::from(err);
|
||||||
|
assert_eq!(err, StorageError::ErasureWriteQuorum);
|
||||||
|
assert!(err.is_quorum_error());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
sync::{Arc, OnceLock},
|
sync::{Arc, LazyLock, OnceLock, RwLock as StdRwLock},
|
||||||
time::SystemTime,
|
time::SystemTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +57,13 @@ use uuid::Uuid;
|
|||||||
const TEST_RPC_SECRET: &str = "test-rpc-secret";
|
const TEST_RPC_SECRET: &str = "test-rpc-secret";
|
||||||
|
|
||||||
pub(crate) type WorkloadSnapshotProviderRef = Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>;
|
pub(crate) type WorkloadSnapshotProviderRef = Arc<dyn WorkloadAdmissionSnapshotProvider + Send + Sync>;
|
||||||
|
pub type ScannerDirtyUsageMutationObserver = Arc<dyn Fn(&str, &str, ScannerDirtyUsageMutationSource) + Send + Sync + 'static>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ScannerDirtyUsageMutationSource {
|
||||||
|
Replication,
|
||||||
|
TierExpiration,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub(crate) struct LockRegistry {
|
pub(crate) struct LockRegistry {
|
||||||
@@ -88,6 +95,8 @@ impl LockRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER: OnceLock<WorkloadSnapshotProviderRef> = OnceLock::new();
|
static WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER: OnceLock<WorkloadSnapshotProviderRef> = OnceLock::new();
|
||||||
|
static SCANNER_DIRTY_USAGE_MUTATION_OBSERVER: LazyLock<StdRwLock<Option<ScannerDirtyUsageMutationObserver>>> =
|
||||||
|
LazyLock::new(|| StdRwLock::new(None));
|
||||||
|
|
||||||
pub(crate) fn set_workload_admission_snapshot_provider(
|
pub(crate) fn set_workload_admission_snapshot_provider(
|
||||||
provider: WorkloadSnapshotProviderRef,
|
provider: WorkloadSnapshotProviderRef,
|
||||||
@@ -99,6 +108,28 @@ pub(crate) fn workload_admission_snapshot_provider() -> Option<WorkloadSnapshotP
|
|||||||
WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER.get().cloned()
|
WORKLOAD_ADMISSION_SNAPSHOT_PROVIDER.get().cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_scanner_dirty_usage_mutation_observer(
|
||||||
|
observer: Option<ScannerDirtyUsageMutationObserver>,
|
||||||
|
) -> Option<ScannerDirtyUsageMutationObserver> {
|
||||||
|
let mut slot = SCANNER_DIRTY_USAGE_MUTATION_OBSERVER
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
std::mem::replace(&mut *slot, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn notify_scanner_dirty_usage_mutation(bucket: &str, object: &str, source: ScannerDirtyUsageMutationSource) {
|
||||||
|
if bucket.is_empty() || object.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let observer = SCANNER_DIRTY_USAGE_MUTATION_OBSERVER
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone();
|
||||||
|
if let Some(observer) = observer {
|
||||||
|
observer(bucket, object, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn record_erasure_write_quorum_failure(stage: &'static str, dominant_error: &'static str) {
|
pub(crate) fn record_erasure_write_quorum_failure(stage: &'static str, dominant_error: &'static str) {
|
||||||
global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error);
|
global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error);
|
||||||
}
|
}
|
||||||
@@ -580,12 +611,16 @@ pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
LockRegistry, clear_local_disk_id_map_for_test, local_disk_path_by_id, local_node_name, reconcile_local_disk_ids,
|
LockRegistry, ScannerDirtyUsageMutationSource, clear_local_disk_id_map_for_test, local_disk_path_by_id, local_node_name,
|
||||||
replace_local_disk_id, set_local_node_name,
|
notify_scanner_dirty_usage_mutation, reconcile_local_disk_ids, replace_local_disk_id, set_local_node_name,
|
||||||
|
set_scanner_dirty_usage_mutation_observer,
|
||||||
};
|
};
|
||||||
use crate::disk::endpoint::Endpoint;
|
use crate::disk::endpoint::Endpoint;
|
||||||
use rustfs_lock::{LocalClient, LockClient};
|
use rustfs_lock::{LocalClient, LockClient};
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn url_endpoint(raw: &str) -> Endpoint {
|
fn url_endpoint(raw: &str) -> Endpoint {
|
||||||
@@ -620,6 +655,42 @@ mod tests {
|
|||||||
assert!(Arc::ptr_eq(&clients[1], &client_b));
|
assert!(Arc::ptr_eq(&clients[1], &client_b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial(scanner_dirty_usage_mutation_observer)]
|
||||||
|
fn scanner_dirty_usage_mutation_observer_filters_empty_identity_and_preserves_source() {
|
||||||
|
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let observed_clone = Arc::clone(&observed);
|
||||||
|
let previous = set_scanner_dirty_usage_mutation_observer(Some(Arc::new(move |bucket, object, source| {
|
||||||
|
observed_clone.lock().expect("observer lock should not be poisoned").push((
|
||||||
|
bucket.to_string(),
|
||||||
|
object.to_string(),
|
||||||
|
source,
|
||||||
|
));
|
||||||
|
})));
|
||||||
|
|
||||||
|
notify_scanner_dirty_usage_mutation("photos", "2026/image.jpg", ScannerDirtyUsageMutationSource::Replication);
|
||||||
|
notify_scanner_dirty_usage_mutation("", "2026/empty-bucket.jpg", ScannerDirtyUsageMutationSource::TierExpiration);
|
||||||
|
notify_scanner_dirty_usage_mutation("photos", "", ScannerDirtyUsageMutationSource::TierExpiration);
|
||||||
|
notify_scanner_dirty_usage_mutation("archive", "expired.bin", ScannerDirtyUsageMutationSource::TierExpiration);
|
||||||
|
set_scanner_dirty_usage_mutation_observer(previous);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*observed.lock().expect("observer lock should not be poisoned"),
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"photos".to_string(),
|
||||||
|
"2026/image.jpg".to_string(),
|
||||||
|
ScannerDirtyUsageMutationSource::Replication
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"archive".to_string(),
|
||||||
|
"expired.bin".to_string(),
|
||||||
|
ScannerDirtyUsageMutationSource::TierExpiration
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn local_node_name_round_trips_through_common_runtime_helper() {
|
async fn local_node_name_round_trips_through_common_runtime_helper() {
|
||||||
|
|||||||
@@ -67,12 +67,9 @@ const DECOMMISSION_TARGET_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
|
|||||||
// Keep this synchronized with the version served by node_service. Including
|
// Keep this synchronized with the version served by node_service. Including
|
||||||
// the local member in the minimum prevents an older coordinator from
|
// the local member in the minimum prevents an older coordinator from
|
||||||
// self-authorizing a policy implemented only by newer remote peers.
|
// self-authorizing a policy implemented only by newer remote peers.
|
||||||
const LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION: u32 = 4;
|
const LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION: u32 = 5;
|
||||||
/// Version 5 is reserved for a fleet whose every metadata writer preserves
|
/// Version 5 preserves explicit transition state/destination bindings and
|
||||||
/// explicit transition version state and destination identity, and implements
|
/// supports exact-generation metadata repair with strong all-copy readback.
|
||||||
/// conditional per-generation `xl.meta` writes with strong readback. The node
|
|
||||||
/// service must not advertise this version until the conditional writer from
|
|
||||||
/// rustfs/backlog#684 is available.
|
|
||||||
const LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION: u32 = 5;
|
const LEGACY_TRANSITION_STATE_RECONCILE_POLICY_SUPPORTED_VERSION: u32 = 5;
|
||||||
|
|
||||||
fn resolve_admin_peer_probe_timeout_secs(configured: Option<u64>) -> u64 {
|
fn resolve_admin_peer_probe_timeout_secs(configured: Option<u64>) -> u64 {
|
||||||
@@ -568,6 +565,10 @@ pub(crate) fn tier_delete_journal_topology_generation(proof: &TierDeleteJournalF
|
|||||||
stable_tier_delete_journal_topology_generation(&proof.token.topology_fingerprint)
|
stable_tier_delete_journal_topology_generation(&proof.token.topology_fingerprint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cross_pool_fence_topology_generation(proof: &CrossPoolFenceFleetProofToken) -> String {
|
||||||
|
stable_tier_delete_journal_topology_generation(&proof.0.topology_fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
/// Acquire one non-cloneable authority that must span the complete reconcile
|
/// Acquire one non-cloneable authority that must span the complete reconcile
|
||||||
/// effect window, including its final strong readback.
|
/// effect window, including its final strong readback.
|
||||||
pub async fn acquire_legacy_transition_state_reconcile_fleet_proof() -> Option<LegacyTransitionStateReconcileFleetProofToken> {
|
pub async fn acquire_legacy_transition_state_reconcile_fleet_proof() -> Option<LegacyTransitionStateReconcileFleetProofToken> {
|
||||||
@@ -603,6 +604,10 @@ fn acquire_legacy_transition_state_reconcile_fleet_proof_from(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn observe_legacy_transition_state_reconcile_fleet(expected_topology: &str) -> Option<BTreeMap<String, Uuid>> {
|
async fn observe_legacy_transition_state_reconcile_fleet(expected_topology: &str) -> Option<BTreeMap<String, Uuid>> {
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
if let Ok(observation) = LEGACY_RECONCILE_TEST_OBSERVATION.try_with(Clone::clone) {
|
||||||
|
return Some(observation);
|
||||||
|
}
|
||||||
let notification_sys = get_global_notification_sys()?;
|
let notification_sys = get_global_notification_sys()?;
|
||||||
let (peer_epochs, minimum_version) = timeout(
|
let (peer_epochs, minimum_version) = timeout(
|
||||||
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
|
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
|
||||||
@@ -615,6 +620,34 @@ async fn observe_legacy_transition_state_reconcile_fleet(expected_topology: &str
|
|||||||
reconcile_result.ok()
|
reconcile_result.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
tokio::task_local! {
|
||||||
|
static LEGACY_RECONCILE_TEST_OBSERVATION: BTreeMap<String, Uuid>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
pub(crate) async fn with_legacy_transition_state_fleet_proof_for_test<F: std::future::Future>(future: F) -> F::Output {
|
||||||
|
struct Revoke;
|
||||||
|
impl Drop for Revoke {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
revoke_fleet_capability_proof(legacy_transition_state_reconcile_fleet_proof_slot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get().expect("test store topology");
|
||||||
|
assert!(
|
||||||
|
publish_fleet_capability_probe_result(
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_slot(),
|
||||||
|
topology,
|
||||||
|
Ok(BTreeMap::new()),
|
||||||
|
Instant::now(),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let _revoke = Revoke;
|
||||||
|
let _remote_version = install_current_remote_version_state_fleet_proof_for_test();
|
||||||
|
LEGACY_RECONCILE_TEST_OBSERVATION.scope(BTreeMap::new(), future).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Revalidate the exact fleet generation captured by a reconcile token with a
|
/// Revalidate the exact fleet generation captured by a reconcile token with a
|
||||||
/// fresh synchronous observation. Callers must await this before each
|
/// fresh synchronous observation. Callers must await this before each
|
||||||
/// conditional metadata write and after the final strong readback.
|
/// conditional metadata write and after the final strong readback.
|
||||||
@@ -633,6 +666,18 @@ pub async fn legacy_transition_state_reconcile_fleet_proof_matches(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Final local check in the disk publication executor. The corresponding
|
||||||
|
/// counted permit remains owned until the filesystem operation has drained.
|
||||||
|
pub(crate) fn legacy_transition_state_reconcile_fleet_proof_current(
|
||||||
|
proof: &LegacyTransitionStateReconcileFleetProofToken,
|
||||||
|
) -> bool {
|
||||||
|
let Some(topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else { return false };
|
||||||
|
let state = legacy_transition_state_reconcile_fleet_proof_slot()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
legacy_transition_state_reconcile_fleet_proof_matches_at(&state, proof, topology, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn acquire_ilm_recovery_export_fleet_proof() -> Option<IlmRecoveryExportFleetProofToken> {
|
pub async fn acquire_ilm_recovery_export_fleet_proof() -> Option<IlmRecoveryExportFleetProofToken> {
|
||||||
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
|
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
|
||||||
let proof = {
|
let proof = {
|
||||||
@@ -1120,6 +1165,14 @@ pub(crate) fn install_remote_version_state_fleet_proof_for_test(topology_fingerp
|
|||||||
RemoteVersionStateFleetProofGuard
|
RemoteVersionStateFleetProofGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
|
pub(crate) fn install_current_remote_version_state_fleet_proof_for_test() -> RemoteVersionStateFleetProofGuard {
|
||||||
|
let topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY
|
||||||
|
.get()
|
||||||
|
.expect("the test store must bind its fleet topology before installing a writer proof");
|
||||||
|
install_remote_version_state_fleet_proof_for_test(topology)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "test-util"))]
|
#[cfg(all(test, feature = "test-util"))]
|
||||||
pub(crate) struct TransitionTransactionCompactionFleetProofGuard;
|
pub(crate) struct TransitionTransactionCompactionFleetProofGuard;
|
||||||
|
|
||||||
@@ -3914,15 +3967,11 @@ mod tests {
|
|||||||
assert!(decommission_v3.is_err(), "v3 members do not understand the per-target decommission fence");
|
assert!(decommission_v3.is_err(), "v3 members do not understand the per-target decommission fence");
|
||||||
assert!(reconcile_v3.is_err());
|
assert!(reconcile_v3.is_err());
|
||||||
|
|
||||||
let (generic_v4, journal_v4, decommission_v4, reconcile_v4) =
|
let (generic_v4, journal_v4, decommission_v4, reconcile_v4) = cross_pool_fence_policy_results(peers.clone(), 4);
|
||||||
cross_pool_fence_policy_results(peers.clone(), LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION);
|
|
||||||
assert!(generic_v4.is_ok());
|
assert!(generic_v4.is_ok());
|
||||||
assert!(journal_v4.is_ok());
|
assert!(journal_v4.is_ok());
|
||||||
assert!(decommission_v4.is_ok(), "an all-v4 fleet may create sticky per-target reservations");
|
assert!(decommission_v4.is_ok(), "an all-v4 fleet may create sticky per-target reservations");
|
||||||
assert!(
|
assert!(reconcile_v4.is_err(), "v4 does not support conditional transition metadata writes");
|
||||||
reconcile_v4.is_err(),
|
|
||||||
"the current local policy lacks the conditional xl.meta writer required by reconcile"
|
|
||||||
);
|
|
||||||
|
|
||||||
let (generic_v5, journal_v5, decommission_v5, reconcile_v5) = cross_pool_fence_policy_results(peers, 5);
|
let (generic_v5, journal_v5, decommission_v5, reconcile_v5) = cross_pool_fence_policy_results(peers, 5);
|
||||||
assert!(generic_v5.is_ok());
|
assert!(generic_v5.is_ok());
|
||||||
@@ -4600,7 +4649,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn legacy_transition_state_reconcile_single_node_stays_closed_before_local_cas_support() {
|
async fn legacy_transition_state_reconcile_single_node_advertises_conditional_writer() {
|
||||||
let notification_sys = NotificationSys {
|
let notification_sys = NotificationSys {
|
||||||
peer_clients: Vec::new(),
|
peer_clients: Vec::new(),
|
||||||
all_peer_clients: vec![None],
|
all_peer_clients: vec![None],
|
||||||
@@ -4616,8 +4665,8 @@ mod tests {
|
|||||||
assert_eq!(minimum_version, LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION);
|
assert_eq!(minimum_version, LOCAL_CROSS_POOL_FENCE_POLICY_SUPPORTED_VERSION);
|
||||||
let (_, _, _, reconcile_result) = cross_pool_fence_policy_results(peers, minimum_version);
|
let (_, _, _, reconcile_result) = cross_pool_fence_policy_results(peers, minimum_version);
|
||||||
assert!(
|
assert!(
|
||||||
reconcile_result.is_err(),
|
reconcile_result.is_ok(),
|
||||||
"the current node must not self-authorize reconcile before the conditional writer lands"
|
"the current node implements the conditional writer and preserves repaired bindings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ impl ECStore {
|
|||||||
where
|
where
|
||||||
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
S: EcstoreObjectIO + StorageNamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||||
{
|
{
|
||||||
// Lock order: pool_meta_save_gate -> pool.bin -> rebalance.bin.
|
// Lock order: pool_meta_save_gate -> rebalance.bin -> pool.bin.
|
||||||
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
let mut pool_meta_guard = self.pool_meta_save_gate.lock().await;
|
||||||
pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
|
pool_meta_guard.ensure_write_safe("rebalance worker activation")?;
|
||||||
// Classify the durable rebalance record while holding both namespace
|
// Classify the durable rebalance record while holding both namespace
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ fn ensure_rebalance_entry_active(cancel: &CancellationToken) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
tokio::task_local! {
|
||||||
|
static REBALANCE_ENTRY_RUN_FENCE_BARRIER: (Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct RebalanceEntryTarget {
|
struct RebalanceEntryTarget {
|
||||||
bucket: String,
|
bucket: String,
|
||||||
@@ -256,9 +261,15 @@ impl ECStore {
|
|||||||
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
|
.sort_by_key(|v| (v.mod_time.is_none(), std::cmp::Reverse(v.mod_time)));
|
||||||
|
|
||||||
// Entry lock order is bucket incarnation -> activation_gate -> rebalance.bin -> movement gate.
|
// Entry lock order is bucket incarnation -> activation_gate -> rebalance.bin -> movement gate.
|
||||||
|
// Target capacity admission can then acquire pool.bin under the run fence.
|
||||||
// Stop waits for in-flight entries through cleanup, but not for entries admitted later.
|
// Stop waits for in-flight entries through cleanup, but not for entries admitted later.
|
||||||
ensure_rebalance_entry_active(&cancel)?;
|
ensure_rebalance_entry_active(&cancel)?;
|
||||||
let run_guard = self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry").await?;
|
let run_guard = self.rebalance_run_guard(rebalance_id.as_ref(), "rebalance entry").await?;
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Ok((arrived, release)) = REBALANCE_ENTRY_RUN_FENCE_BARRIER.try_with(Clone::clone) {
|
||||||
|
arrived.notify_one();
|
||||||
|
release.notified().await;
|
||||||
|
}
|
||||||
let lock_lost_signal = run_guard.lock_lost_signal();
|
let lock_lost_signal = run_guard.lock_lost_signal();
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
let _run_signal_test_fence = lock_lost_signal
|
let _run_signal_test_fence = lock_lost_signal
|
||||||
@@ -1237,6 +1248,130 @@ mod tests {
|
|||||||
assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning");
|
assert_eq!(pool_stats.cleanup_warnings.count, 1, "deferred cleanup must not add a permanent warning");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn real_rebalance_entry_progresses_while_peer_activation_waits_for_run_fence() {
|
||||||
|
const REBALANCE_ID: &str = "rebalance-peer-activation-lock-order";
|
||||||
|
let (_temp_dirs, store, peer) = crate::services::rebalance::test_two_pool_stores_with_isolated_node_contexts(Some(
|
||||||
|
active_rebalance_meta(REBALANCE_ID),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
assert!(!Arc::ptr_eq(&store.ctx, &peer.ctx), "node-local movement gates must be independent");
|
||||||
|
{
|
||||||
|
let mut meta = peer.rebalance_meta.write().await;
|
||||||
|
let meta = meta.as_mut().expect("peer should know the durable run");
|
||||||
|
meta.activation_gate = Arc::default();
|
||||||
|
meta.cancel = None;
|
||||||
|
}
|
||||||
|
let bucket = crate::disk::RUSTFS_META_BUCKET;
|
||||||
|
let object = "rebalance-peer-activation-object";
|
||||||
|
let version_id = uuid::Uuid::new_v4();
|
||||||
|
let payload = b"entry must drain before peer activation takes the pool fence".repeat(1024);
|
||||||
|
let source_set = store.pools[0].get_disks_by_key(object);
|
||||||
|
let target_set = store.pools[1].get_disks_by_key(object);
|
||||||
|
let opts = ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
version_id: Some(version_id.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut writer = PutObjReader::from_vec(payload.clone());
|
||||||
|
let source_before = source_set
|
||||||
|
.put_object(bucket, object, &mut writer, &opts)
|
||||||
|
.await
|
||||||
|
.expect("source version should be written");
|
||||||
|
let entry = metacache_entry_from_source(&source_set, bucket, object).await;
|
||||||
|
let arrived = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let release = Arc::new(tokio::sync::Notify::new());
|
||||||
|
// JoinSet aborts both scoped tasks if an assertion or timeout fails.
|
||||||
|
let mut tasks = tokio::task::JoinSet::new();
|
||||||
|
let entry_store = Arc::clone(&store);
|
||||||
|
tasks.spawn(
|
||||||
|
REBALANCE_ENTRY_RUN_FENCE_BARRIER.scope((Arc::clone(&arrived), Arc::clone(&release)), async move {
|
||||||
|
entry_store
|
||||||
|
.rebalance_entry(
|
||||||
|
RebalanceEntryTarget {
|
||||||
|
bucket: bucket.to_string(),
|
||||||
|
pool_index: 0,
|
||||||
|
},
|
||||||
|
entry,
|
||||||
|
source_set,
|
||||||
|
Arc::new(RebalanceBucketConfigs::default()),
|
||||||
|
Arc::from(REBALANCE_ID),
|
||||||
|
CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
tokio::time::timeout(StdDuration::from_secs(30), arrived.notified())
|
||||||
|
.await
|
||||||
|
.expect("real entry must acquire its persisted run read fence");
|
||||||
|
|
||||||
|
let attempted = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let peer_pool = Arc::clone(&peer.pools[0]);
|
||||||
|
let (activation_done, activation_result) = tokio::sync::oneshot::channel();
|
||||||
|
tasks.spawn(
|
||||||
|
crate::core::pools::REBALANCE_ACTIVATION_LOCK_ATTEMPT.scope(Arc::clone(&attempted), async move {
|
||||||
|
let result = peer.fence_rebalance_worker_activation(peer_pool, REBALANCE_ID).await;
|
||||||
|
let result = result.map(|fence| match fence {
|
||||||
|
super::super::control::RebalanceWorkerActivationFence::Ready(fence) => {
|
||||||
|
fence.ensure_held().expect("peer activation must retain both fences");
|
||||||
|
}
|
||||||
|
super::super::control::RebalanceWorkerActivationFence::NotStartedTerminal => {
|
||||||
|
panic!("the paused entry's run must still require activation");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
activation_done.send(result).expect("activation receiver should remain alive");
|
||||||
|
Ok(RebalanceEntryOutcome::Completed)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
tokio::time::timeout(StdDuration::from_secs(30), attempted.notified())
|
||||||
|
.await
|
||||||
|
.expect("peer activation must attempt the persisted rebalance write fence");
|
||||||
|
release.notify_one();
|
||||||
|
|
||||||
|
tokio::time::timeout(StdDuration::from_secs(30), async {
|
||||||
|
while let Some(result) = tasks.join_next().await {
|
||||||
|
assert!(matches!(
|
||||||
|
result
|
||||||
|
.expect("scoped task must not panic")
|
||||||
|
.expect("entry must not fail or defer"),
|
||||||
|
RebalanceEntryOutcome::Completed
|
||||||
|
));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("entry and peer activation must both make progress");
|
||||||
|
activation_result
|
||||||
|
.await
|
||||||
|
.expect("peer activation result should be sent")
|
||||||
|
.expect("peer activation must not time out behind the entry it blocks");
|
||||||
|
|
||||||
|
let mut reader = target_set
|
||||||
|
.get_object_reader(bucket, object, None, HeaderMap::new(), &opts)
|
||||||
|
.await
|
||||||
|
.expect("the exact target version must be readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("target body should drain completely");
|
||||||
|
assert_eq!(actual, payload);
|
||||||
|
assert_eq!(reader.object_info.version_id, source_before.version_id);
|
||||||
|
assert_eq!(reader.object_info.etag, source_before.etag);
|
||||||
|
assert_eq!(reader.object_info.mod_time, source_before.mod_time);
|
||||||
|
let source_error = store.pools[0]
|
||||||
|
.get_object_info(bucket, object, &opts)
|
||||||
|
.await
|
||||||
|
.expect_err("completed entry must clean up the source version");
|
||||||
|
assert!(crate::error::is_err_object_not_found(&source_error) || crate::error::is_err_version_not_found(&source_error));
|
||||||
|
let meta = store.rebalance_meta.read().await;
|
||||||
|
let stats = &meta.as_ref().expect("local run must remain installed").pool_stats[0];
|
||||||
|
assert_eq!(stats.num_objects, 1);
|
||||||
|
assert_eq!(stats.num_versions, 1);
|
||||||
|
assert_eq!(stats.cleanup_warnings.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn real_rebalance_run_fence_loss_before_target_commit_preserves_target_and_source() {
|
async fn real_rebalance_run_fence_loss_before_target_commit_preserves_target_and_source() {
|
||||||
|
|||||||
@@ -1907,6 +1907,124 @@ fn test_is_transient_rebalance_error_accepts_wrapped_disk_timeout() {
|
|||||||
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other(DiskError::Timeout))));
|
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other(DiskError::Timeout))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rebalance_stage_wrapped_transient_errors_remain_retryable() {
|
||||||
|
let cases = [
|
||||||
|
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
|
||||||
|
Error::Lock(rustfs_lock::LockError::network(
|
||||||
|
"peer unavailable",
|
||||||
|
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
|
||||||
|
)),
|
||||||
|
Error::SlowDown,
|
||||||
|
Error::ErasureReadQuorum,
|
||||||
|
Error::ErasureWriteQuorum,
|
||||||
|
Error::Io(std::io::Error::other(DiskError::Timeout)),
|
||||||
|
Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut)),
|
||||||
|
];
|
||||||
|
for mut error in cases {
|
||||||
|
for depth in 0..=3 {
|
||||||
|
assert!(is_transient_rebalance_error(&error), "transient source lost at depth {depth}: {error:?}");
|
||||||
|
assert!(
|
||||||
|
should_defer_rebalance_entry_failure(&error),
|
||||||
|
"exhausted transient entries must be deferred"
|
||||||
|
);
|
||||||
|
assert!(should_retry_rebalance_listing(&error, 0, 3));
|
||||||
|
assert!(
|
||||||
|
!should_retry_rebalance_listing(&error, 2, 3),
|
||||||
|
"wrapping must not bypass the attempt limit"
|
||||||
|
);
|
||||||
|
error = data_movement::data_movement_stage_error_for_test(
|
||||||
|
"rebalance_object",
|
||||||
|
"put_object",
|
||||||
|
"bucket",
|
||||||
|
"baseline/00042.bin",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rebalance_stage_wrapped_terminal_errors_remain_terminal() {
|
||||||
|
let cases = [
|
||||||
|
Error::FileAccessDenied,
|
||||||
|
Error::FileCorrupt,
|
||||||
|
Error::OperationCanceled,
|
||||||
|
Error::DataMovementOverwriteErr("bucket".to_string(), "object".to_string(), "version".to_string()),
|
||||||
|
Error::Lock(rustfs_lock::LockError::already_locked("bucket/object", "owner")),
|
||||||
|
Error::other("permission denied"),
|
||||||
|
];
|
||||||
|
for mut error in cases {
|
||||||
|
for depth in 0..=3 {
|
||||||
|
assert!(
|
||||||
|
!is_transient_rebalance_error(&error),
|
||||||
|
"terminal source must survive depth {depth}: {error:?}"
|
||||||
|
);
|
||||||
|
assert!(!should_defer_rebalance_entry_failure(&error));
|
||||||
|
// Object names are untrusted context, not evidence of a transient failure.
|
||||||
|
error = data_movement::data_movement_stage_error_for_test(
|
||||||
|
"rebalance_object",
|
||||||
|
"put_object",
|
||||||
|
"bucket",
|
||||||
|
"remote lock rpc timed out",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rebalance_stage_wrapped_lock_timeout_retries_real_migration_loop() {
|
||||||
|
for succeeds_on_retry in [true, false] {
|
||||||
|
let backend = MigrationBackendSpy::new(None, None);
|
||||||
|
let attempts = AtomicUsize::new(0);
|
||||||
|
let waits = AtomicUsize::new(0);
|
||||||
|
let mut transfer = |_, _, _| {
|
||||||
|
let attempt = attempts.fetch_add(1, Ordering::SeqCst);
|
||||||
|
async move {
|
||||||
|
if succeeds_on_retry && attempt > 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(data_movement::data_movement_stage_error_for_test(
|
||||||
|
"rebalance_object",
|
||||||
|
"put_object",
|
||||||
|
"bucket",
|
||||||
|
"baseline/00042.bin",
|
||||||
|
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let version = version_normal();
|
||||||
|
let result = migrate_entry_version_with_retry_wait(
|
||||||
|
&backend,
|
||||||
|
"bucket".to_string(),
|
||||||
|
0,
|
||||||
|
&version,
|
||||||
|
None,
|
||||||
|
3,
|
||||||
|
false,
|
||||||
|
&mut transfer,
|
||||||
|
|_: String, _: String, _: ObjectOptions| async { Ok::<_, Error>(ObjectInfo::default()) },
|
||||||
|
|_| {
|
||||||
|
waits.fetch_add(1, Ordering::SeqCst);
|
||||||
|
std::future::ready(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(result.moved, succeeds_on_retry);
|
||||||
|
assert_eq!(result.failed, !succeeds_on_retry);
|
||||||
|
assert_eq!(attempts.load(Ordering::SeqCst), if succeeds_on_retry { 2 } else { 3 });
|
||||||
|
assert_eq!(backend.get_calls(), attempts.load(Ordering::SeqCst));
|
||||||
|
assert_eq!(waits.load(Ordering::SeqCst), attempts.load(Ordering::SeqCst) - 1);
|
||||||
|
if !succeeds_on_retry {
|
||||||
|
assert_eq!(result.stage, Some("write_target"));
|
||||||
|
assert!(should_defer_rebalance_entry_failure(
|
||||||
|
result.error.as_ref().expect("exhaustion must retain its source error")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_transient_rebalance_error_accepts_io_timeout_message() {
|
fn test_is_transient_rebalance_error_accepts_io_timeout_message() {
|
||||||
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other("timeout"))));
|
assert!(is_transient_rebalance_error(&Error::Io(std::io::Error::other("timeout"))));
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ pub(super) fn resolve_rebalance_bucket_result(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
|
pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
|
||||||
|
let err = rebalance_error_source(err);
|
||||||
match err {
|
match err {
|
||||||
Error::SlowDown
|
Error::SlowDown
|
||||||
| Error::ErasureReadQuorum
|
| Error::ErasureReadQuorum
|
||||||
@@ -256,6 +257,15 @@ pub(super) fn is_transient_rebalance_error(err: &Error) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rebalance_error_source(mut err: &Error) -> &Error {
|
||||||
|
// Stage context contains object names, so classify the preserved source,
|
||||||
|
// not timeout-like text supplied by an object name. Iterate nested stages.
|
||||||
|
while let Some(source) = crate::data_movement::data_movement_stage_source(err) {
|
||||||
|
err = source;
|
||||||
|
}
|
||||||
|
err
|
||||||
|
}
|
||||||
|
|
||||||
fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool {
|
fn is_rebalance_transient_lock_error(err: &rustfs_lock::LockError) -> bool {
|
||||||
match err {
|
match err {
|
||||||
rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::Network { .. } => true,
|
rustfs_lock::LockError::Timeout { .. } | rustfs_lock::LockError::Network { .. } => true,
|
||||||
@@ -309,6 +319,7 @@ pub(super) fn rebalance_listing_retry_delay(attempt: usize) -> Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool {
|
fn is_rebalance_lock_or_rpc_timeout(err: &Error) -> bool {
|
||||||
|
let err = rebalance_error_source(err);
|
||||||
match err {
|
match err {
|
||||||
Error::Lock(rustfs_lock::LockError::Timeout { .. }) | Error::Lock(rustfs_lock::LockError::Network { .. }) => true,
|
Error::Lock(rustfs_lock::LockError::Timeout { .. }) | Error::Lock(rustfs_lock::LockError::Network { .. }) => true,
|
||||||
Error::Io(io_err) => is_rebalance_lock_or_rpc_timeout_message(&io_err.to_string()),
|
Error::Io(io_err) => is_rebalance_lock_or_rpc_timeout_message(&io_err.to_string()),
|
||||||
@@ -585,3 +596,48 @@ impl SetDisks {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod error_source_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_wrapped_errors_select_the_source_backoff_policy() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
Error::Lock(rustfs_lock::LockError::timeout(".rustfs.sys/pool.bin@latest", Duration::from_secs(5))),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Error::Lock(rustfs_lock::LockError::network(
|
||||||
|
"peer unavailable",
|
||||||
|
std::io::Error::from(std::io::ErrorKind::ConnectionReset),
|
||||||
|
)),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(Error::other("remote lock rpc timed out"), true),
|
||||||
|
(Error::SlowDown, false),
|
||||||
|
(Error::Io(std::io::Error::other(DiskError::Timeout)), false),
|
||||||
|
(Error::FileAccessDenied, false),
|
||||||
|
];
|
||||||
|
for (mut error, lock_backoff) in cases {
|
||||||
|
for depth in 0..=3 {
|
||||||
|
assert_eq!(
|
||||||
|
is_rebalance_lock_or_rpc_timeout(&error),
|
||||||
|
lock_backoff,
|
||||||
|
"wrong backoff at depth {depth}: {error:?}"
|
||||||
|
);
|
||||||
|
if !lock_backoff {
|
||||||
|
assert_eq!(rebalance_migration_retry_delay(1, &error), REBALANCE_MIGRATION_RETRY_BASE_DELAY * 2);
|
||||||
|
}
|
||||||
|
error = crate::data_movement::data_movement_stage_error_for_test(
|
||||||
|
"rebalance_object",
|
||||||
|
"put_object",
|
||||||
|
"bucket",
|
||||||
|
"remote lock rpc timed out",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -653,6 +653,26 @@ impl MockWarmBackend {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WarmBackend for MockWarmBackend {
|
impl WarmBackend for MockWarmBackend {
|
||||||
|
async fn probe_legacy_metadata(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
use super::warm_backend::LegacyTransitionStateProbe as Probe;
|
||||||
|
let candidate = match remote_version {
|
||||||
|
Some(version) if !version.is_empty() => self.probe_transition_version(object, version).await?,
|
||||||
|
_ => self.probe_transition_candidate(object).await?,
|
||||||
|
};
|
||||||
|
Ok(match candidate {
|
||||||
|
TransitionCandidateProbe::Missing => Probe::Missing,
|
||||||
|
TransitionCandidateProbe::UnversionedPresent => Probe::UnversionedPresent,
|
||||||
|
TransitionCandidateProbe::VersionedPresent(version) if version == "null" => Probe::SuspendedNullPresent,
|
||||||
|
TransitionCandidateProbe::VersionedPresent(version) => Probe::VersionedPresent(version),
|
||||||
|
TransitionCandidateProbe::Ambiguous => Probe::Ambiguous,
|
||||||
|
TransitionCandidateProbe::Unsupported => Probe::Unsupported,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> {
|
fn validate_remote_version_id(&self, remote_version_id: &str) -> Result<(), std::io::Error> {
|
||||||
if remote_version_id.is_empty() {
|
if remote_version_id.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -874,8 +894,9 @@ pub async fn register_mock_tier_backend(handle: &Arc<RwLock<TierConfigMgr>>, tie
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
tier_config_mgr
|
drop(tier_config_mgr);
|
||||||
.install_test_driver(tier_name, Box::new(backend))
|
TierConfigMgr::install_test_driver_in(handle, tier_name, Box::new(backend))
|
||||||
|
.await
|
||||||
.expect("mock tier driver should install");
|
.expect("mock tier driver should install");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2322,6 +2322,14 @@ struct SharedWarmBackendProxy(SharedWarmBackend);
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl WarmBackend for SharedWarmBackendProxy {
|
impl WarmBackend for SharedWarmBackendProxy {
|
||||||
|
async fn probe_legacy_metadata(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> io::Result<crate::services::tier::warm_backend::LegacyTransitionStateProbe> {
|
||||||
|
self.0.probe_legacy_metadata(object, remote_version).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn validate(&self) -> io::Result<()> {
|
async fn validate(&self) -> io::Result<()> {
|
||||||
self.0.validate().await
|
self.0.validate().await
|
||||||
}
|
}
|
||||||
@@ -2490,6 +2498,27 @@ impl TierOperationLease {
|
|||||||
self.inner.driver.probe_transition_version(object, remote_version_id).await
|
self.inner.driver.probe_transition_version(object, remote_version_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn probe_legacy_transition_state(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> io::Result<crate::services::tier::warm_backend::LegacyTransitionStateProbe> {
|
||||||
|
let Some(reconciler) = self
|
||||||
|
.inner
|
||||||
|
.reconciler
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
crate::services::tier::warm_backend::new_transition_candidate_reconciler(&self.inner.tier_config)
|
||||||
|
.await
|
||||||
|
.map(|reconciler| reconciler.map(Arc::from))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| io::Error::other(err.message))?
|
||||||
|
else {
|
||||||
|
return self.inner.driver.probe_legacy_metadata(object, remote_version).await;
|
||||||
|
};
|
||||||
|
reconciler.probe_legacy_transition_state(object, remote_version).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn is_current_generation(&self) -> bool {
|
pub(crate) fn is_current_generation(&self) -> bool {
|
||||||
lock_unpoisoned(&self.runtime)
|
lock_unpoisoned(&self.runtime)
|
||||||
.generations
|
.generations
|
||||||
@@ -6013,6 +6042,19 @@ impl TierConfigMgr {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
|
pub(crate) async fn install_test_driver_in(
|
||||||
|
handle: &Arc<RwLock<Self>>,
|
||||||
|
tier_name: &str,
|
||||||
|
driver: WarmBackendImpl,
|
||||||
|
) -> std::result::Result<(), AdminError> {
|
||||||
|
let mut manager = handle.write().await;
|
||||||
|
// Register the generation runtime before installing the mock so its
|
||||||
|
// explicit lack of a network reconciler survives the first lease.
|
||||||
|
tier_driver_runtime(handle, &manager);
|
||||||
|
manager.install_test_driver(tier_name, driver)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
pub(crate) fn install_test_driver(
|
pub(crate) fn install_test_driver(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ pub enum TransitionCandidateProbe {
|
|||||||
Unsupported,
|
Unsupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Live evidence for repairing legacy metadata. Ordinary candidate GETs do not
|
||||||
|
/// establish the bucket's versioning model and cannot supply this authority.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum LegacyTransitionStateProbe {
|
||||||
|
Missing,
|
||||||
|
UnversionedPresent,
|
||||||
|
SuspendedNullPresent,
|
||||||
|
VersionedPresent(String),
|
||||||
|
Ambiguous,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(crate) struct TransitionCandidateIdentity {
|
pub(crate) struct TransitionCandidateIdentity {
|
||||||
pub transaction_id: uuid::Uuid,
|
pub transaction_id: uuid::Uuid,
|
||||||
@@ -97,6 +109,14 @@ pub(crate) struct TransitionCandidateIdentity {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub(crate) trait TransitionCandidateReconciler {
|
pub(crate) trait TransitionCandidateReconciler {
|
||||||
|
async fn probe_legacy_transition_state(
|
||||||
|
&self,
|
||||||
|
_object: &str,
|
||||||
|
_remote_version: Option<&str>,
|
||||||
|
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
Ok(LegacyTransitionStateProbe::Unsupported)
|
||||||
|
}
|
||||||
|
|
||||||
async fn probe_transition_candidate_for(
|
async fn probe_transition_candidate_for(
|
||||||
&self,
|
&self,
|
||||||
object: &str,
|
object: &str,
|
||||||
@@ -106,6 +126,14 @@ pub(crate) trait TransitionCandidateReconciler {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait WarmBackend {
|
pub trait WarmBackend {
|
||||||
|
async fn probe_legacy_metadata(
|
||||||
|
&self,
|
||||||
|
_object: &str,
|
||||||
|
_remote_version: Option<&str>,
|
||||||
|
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
Ok(LegacyTransitionStateProbe::Unsupported)
|
||||||
|
}
|
||||||
|
|
||||||
async fn validate(&self) -> Result<(), std::io::Error> {
|
async fn validate(&self) -> Result<(), std::io::Error> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -448,6 +476,18 @@ impl MeteredWarmBackend {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl WarmBackend for MeteredWarmBackend {
|
impl WarmBackend for MeteredWarmBackend {
|
||||||
|
async fn probe_legacy_metadata(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
let result = self.inner.probe_legacy_metadata(object, remote_version).await;
|
||||||
|
if matches!(result, Ok(LegacyTransitionStateProbe::Unsupported)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Self::record(TierRequestOperation::Probe, result)
|
||||||
|
}
|
||||||
|
|
||||||
/// Delegated without a counter: only one backend issues a remote request
|
/// Delegated without a counter: only one backend issues a remote request
|
||||||
/// here, and every other one takes the trait default, so a `validate`
|
/// here, and every other one takes the trait default, so a `validate`
|
||||||
/// counter would mostly record requests that never happened.
|
/// counter would mostly record requests that never happened.
|
||||||
@@ -524,6 +564,18 @@ struct MeteredTransitionCandidateReconciler {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl TransitionCandidateReconciler for MeteredTransitionCandidateReconciler {
|
impl TransitionCandidateReconciler for MeteredTransitionCandidateReconciler {
|
||||||
|
async fn probe_legacy_transition_state(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> Result<LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
let result = self.inner.probe_legacy_transition_state(object, remote_version).await;
|
||||||
|
if matches!(result, Ok(LegacyTransitionStateProbe::Unsupported)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
MeteredWarmBackend::record(TierRequestOperation::Probe, result)
|
||||||
|
}
|
||||||
|
|
||||||
async fn probe_transition_candidate_for(
|
async fn probe_transition_candidate_for(
|
||||||
&self,
|
&self,
|
||||||
object: &str,
|
object: &str,
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ impl WarmBackend for WarmBackendMinIO {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendMinIO {
|
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendMinIO {
|
||||||
|
async fn probe_legacy_transition_state(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_legacy_transition_state(
|
||||||
|
&self.0,
|
||||||
|
object,
|
||||||
|
remote_version,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn probe_transition_candidate_for(
|
async fn probe_transition_candidate_for(
|
||||||
&self,
|
&self,
|
||||||
object: &str,
|
object: &str,
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ impl WarmBackend for WarmBackendRustFS {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendRustFS {
|
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendRustFS {
|
||||||
|
async fn probe_legacy_transition_state(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_legacy_transition_state(
|
||||||
|
&self.0,
|
||||||
|
object,
|
||||||
|
remote_version,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn probe_transition_candidate_for(
|
async fn probe_transition_candidate_for(
|
||||||
&self,
|
&self,
|
||||||
object: &str,
|
object: &str,
|
||||||
|
|||||||
@@ -376,7 +376,6 @@ struct TransitionCandidateVersions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TransitionCandidateVersions {
|
impl TransitionCandidateVersions {
|
||||||
#[cfg(test)]
|
|
||||||
fn extend(&mut self, remote_object: &str, versions: &ListVersionsResult) {
|
fn extend(&mut self, remote_object: &str, versions: &ListVersionsResult) {
|
||||||
for version in versions.versions.iter().filter(|version| version.key == remote_object) {
|
for version in versions.versions.iter().filter(|version| version.key == remote_object) {
|
||||||
if self.version_id.is_some() {
|
if self.version_id.is_some() {
|
||||||
@@ -512,6 +511,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn candidate_probe_fixture() -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
|
async fn candidate_probe_fixture() -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
|
||||||
|
scripted_probe_fixture([
|
||||||
|
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nx-amz-version-id: opaque-version\r\nConnection: close\r\n\r\nx",
|
||||||
|
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nConnection: close\r\n\r\nx",
|
||||||
|
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
|
||||||
|
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 66\r\nConnection: close\r\n\r\n<Error><Code>NoSuchObject</Code><Message>missing</Message></Error>",
|
||||||
|
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/xml\r\nContent-Length: 65\r\nConnection: close\r\n\r\n<Error><Code>AccessDenied</Code><Message>denied</Message></Error>",
|
||||||
|
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
|
||||||
|
"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Type: application/xml\r\nContent-Length: 72\r\nConnection: close\r\n\r\n<Error><Code>InvalidRange</Code><Message>empty version</Message></Error>",
|
||||||
|
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 67\r\nConnection: close\r\n\r\n<Error><Code>NoSuchVersion</Code><Message>missing</Message></Error>",
|
||||||
|
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
|
||||||
|
].into_iter().map(str::to_owned).collect()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scripted_probe_fixture(responses: Vec<String>) -> Option<(WarmBackendS3, tokio::task::JoinHandle<Vec<String>>)> {
|
||||||
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
let listener = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
||||||
Ok(listener) => listener,
|
Ok(listener) => listener,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
|
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
|
||||||
@@ -522,17 +535,6 @@ mod tests {
|
|||||||
.expect("listener local address should be available")
|
.expect("listener local address should be available")
|
||||||
.to_string();
|
.to_string();
|
||||||
let fixture = tokio::spawn(async move {
|
let fixture = tokio::spawn(async move {
|
||||||
let responses = [
|
|
||||||
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nx-amz-version-id: opaque-version\r\nConnection: close\r\n\r\nx",
|
|
||||||
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nConnection: close\r\n\r\nx",
|
|
||||||
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
|
|
||||||
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 66\r\nConnection: close\r\n\r\n<Error><Code>NoSuchObject</Code><Message>missing</Message></Error>",
|
|
||||||
"HTTP/1.1 403 Forbidden\r\nContent-Type: application/xml\r\nContent-Length: 65\r\nConnection: close\r\n\r\n<Error><Code>AccessDenied</Code><Message>denied</Message></Error>",
|
|
||||||
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
|
|
||||||
"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Type: application/xml\r\nContent-Length: 72\r\nConnection: close\r\n\r\n<Error><Code>InvalidRange</Code><Message>empty version</Message></Error>",
|
|
||||||
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 67\r\nConnection: close\r\n\r\n<Error><Code>NoSuchVersion</Code><Message>missing</Message></Error>",
|
|
||||||
"HTTP/1.1 404 Not Found\r\nContent-Type: application/xml\r\nContent-Length: 63\r\nConnection: close\r\n\r\n<Error><Code>NoSuchKey</Code><Message>missing</Message></Error>",
|
|
||||||
];
|
|
||||||
let mut requests = Vec::new();
|
let mut requests = Vec::new();
|
||||||
for response in responses {
|
for response in responses {
|
||||||
let (mut stream, _) = listener.accept().await.expect("fixture should accept candidate GET");
|
let (mut stream, _) = listener.accept().await.expect("fixture should accept candidate GET");
|
||||||
@@ -673,6 +675,117 @@ mod tests {
|
|||||||
assert!(requests[8].to_ascii_lowercase().contains("?versionid=historical-version"));
|
assert!(requests[8].to_ascii_lowercase().contains("?versionid=historical-version"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legacy_probe_xml_response(body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_probe_versioning_response(status: &str) -> String {
|
||||||
|
let state = if status.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("<Status>{status}</Status>")
|
||||||
|
};
|
||||||
|
legacy_probe_xml_response(&format!(
|
||||||
|
"<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">{state}</VersioningConfiguration>"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_probe_versions_response(versions: &[&str]) -> String {
|
||||||
|
let versions = versions.iter().map(|version| format!(
|
||||||
|
"<Version><Key>archive/object</Key><VersionId>{version}</VersionId><IsLatest>true</IsLatest><LastModified>2026-09-01T00:00:00Z</LastModified><ETag>\"legacy-etag\"</ETag><Size>7</Size><StorageClass>STANDARD</StorageClass></Version>"
|
||||||
|
)).collect::<String>();
|
||||||
|
legacy_probe_xml_response(&format!(
|
||||||
|
"<ListVersionsResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><Name>bucket</Name><Prefix>archive/object</Prefix><KeyMarker/><VersionIdMarker/><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated>{versions}</ListVersionsResult>"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_transition_state_probe_verifies_disabled_suspended_and_enabled_responses() {
|
||||||
|
use super::super::warm_backend::LegacyTransitionStateProbe as Probe;
|
||||||
|
for (initial, confirmed, version, expected) in [
|
||||||
|
("", "", "null", Probe::UnversionedPresent),
|
||||||
|
("Suspended", "Suspended", "null", Probe::SuspendedNullPresent),
|
||||||
|
("Enabled", "Enabled", "version-a", Probe::VersionedPresent("version-a".to_string())),
|
||||||
|
("Enabled", "Enabled", "null", Probe::SuspendedNullPresent),
|
||||||
|
("", "", "unexpected-version", Probe::Ambiguous),
|
||||||
|
("Suspended", "Enabled", "null", Probe::Ambiguous),
|
||||||
|
] {
|
||||||
|
let responses = vec![
|
||||||
|
legacy_probe_versioning_response(initial),
|
||||||
|
legacy_probe_versions_response(&[version]),
|
||||||
|
legacy_probe_versioning_response(confirmed),
|
||||||
|
];
|
||||||
|
let (backend, fixture) = scripted_probe_fixture(responses)
|
||||||
|
.await
|
||||||
|
.expect("legacy probe loopback fixture");
|
||||||
|
let result =
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), backend.probe_legacy_transition_state("archive/object", None))
|
||||||
|
.await
|
||||||
|
.expect("legacy probe must finish")
|
||||||
|
.expect("legacy probe should decode provider XML");
|
||||||
|
assert_eq!(result, expected, "initial={initial} confirmed={confirmed} version={version}");
|
||||||
|
let requests = fixture.await.expect("legacy probe fixture should finish");
|
||||||
|
assert_eq!(requests.len(), 3);
|
||||||
|
assert!(requests.iter().all(|request| request.starts_with("GET ")));
|
||||||
|
assert!(requests[0].lines().next().expect("request line").contains("versioning"));
|
||||||
|
assert!(requests[1].lines().next().expect("request line").contains("versions"));
|
||||||
|
assert!(requests[2].lines().next().expect("request line").contains("versioning"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_transition_state_probe_preserves_historical_exact_version() {
|
||||||
|
use super::super::warm_backend::LegacyTransitionStateProbe as Probe;
|
||||||
|
let responses = vec![
|
||||||
|
legacy_probe_versioning_response("Enabled"),
|
||||||
|
"HTTP/1.1 206 Partial Content\r\nContent-Length: 1\r\nx-amz-version-id: historical-version\r\nConnection: close\r\n\r\nx".to_string(),
|
||||||
|
legacy_probe_versioning_response("Enabled"),
|
||||||
|
];
|
||||||
|
let (backend, fixture) = scripted_probe_fixture(responses).await.expect("exact legacy probe fixture");
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.probe_legacy_transition_state("archive/object", Some("historical-version"))
|
||||||
|
.await
|
||||||
|
.expect("exact version proof"),
|
||||||
|
Probe::VersionedPresent("historical-version".to_string())
|
||||||
|
);
|
||||||
|
let requests = fixture.await.expect("exact probe fixture should finish");
|
||||||
|
assert!(
|
||||||
|
requests[1]
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.expect("request line")
|
||||||
|
.contains("versionId=historical-version")
|
||||||
|
);
|
||||||
|
assert!(requests[1].to_ascii_lowercase().contains("range: bytes=0-0"));
|
||||||
|
assert!(requests.iter().all(|request| request.starts_with("GET ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_transition_state_probe_retains_multiple_candidates() {
|
||||||
|
use super::super::warm_backend::LegacyTransitionStateProbe as Probe;
|
||||||
|
let responses = vec![
|
||||||
|
legacy_probe_versioning_response("Enabled"),
|
||||||
|
legacy_probe_versions_response(&["version-a", "version-b"]),
|
||||||
|
];
|
||||||
|
let (backend, fixture) = scripted_probe_fixture(responses)
|
||||||
|
.await
|
||||||
|
.expect("ambiguous legacy probe fixture");
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.probe_legacy_transition_state("archive/object", None)
|
||||||
|
.await
|
||||||
|
.expect("ambiguous proof"),
|
||||||
|
Probe::Ambiguous
|
||||||
|
);
|
||||||
|
let requests = fixture.await.expect("ambiguous fixture should finish");
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert!(requests.iter().all(|request| request.starts_with("GET ")));
|
||||||
|
}
|
||||||
|
|
||||||
fn list_versions(versions: &[(&str, &str)], delete_markers: &[(&str, &str)], is_truncated: bool) -> ListVersionsResult {
|
fn list_versions(versions: &[(&str, &str)], delete_markers: &[(&str, &str)], is_truncated: bool) -> ListVersionsResult {
|
||||||
ListVersionsResult {
|
ListVersionsResult {
|
||||||
versions: versions
|
versions: versions
|
||||||
@@ -921,6 +1034,56 @@ impl WarmBackend for WarmBackendS3 {
|
|||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl TransitionCandidateReconciler for WarmBackendS3 {
|
impl TransitionCandidateReconciler for WarmBackendS3 {
|
||||||
|
async fn probe_legacy_transition_state(
|
||||||
|
&self,
|
||||||
|
object: &str,
|
||||||
|
remote_version: Option<&str>,
|
||||||
|
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
use super::warm_backend::LegacyTransitionStateProbe as Probe;
|
||||||
|
|
||||||
|
let initial_versioning = self.remote_bucket_versioning().await?;
|
||||||
|
let candidate = if let Some(version) = remote_version.filter(|version| !version.is_empty()) {
|
||||||
|
validate_remote_version_id(version)?;
|
||||||
|
match self.probe_transition_version(object, version).await? {
|
||||||
|
TransitionCandidateProbe::VersionedPresent(actual) if actual == version => Some(actual),
|
||||||
|
TransitionCandidateProbe::Missing => return Ok(Probe::Missing),
|
||||||
|
_ => return Ok(Probe::Ambiguous),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let remote_object = self.get_dest(object);
|
||||||
|
let mut opts = ListObjectsOptions::default();
|
||||||
|
opts.set("prefix", &remote_object);
|
||||||
|
opts.set("max-keys", "1000");
|
||||||
|
let mut key_marker = String::new();
|
||||||
|
let mut version_marker = String::new();
|
||||||
|
let mut candidates = TransitionCandidateVersions::default();
|
||||||
|
let mut complete = false;
|
||||||
|
// This is one synchronous record inspection, not an unbounded
|
||||||
|
// remote history scan. The caller also bounds the whole probe.
|
||||||
|
for _ in 0..128 {
|
||||||
|
let page = self
|
||||||
|
.client
|
||||||
|
.list_object_versions_query(&self.bucket, &opts, &key_marker, &version_marker, "")
|
||||||
|
.await?;
|
||||||
|
candidates.extend(&remote_object, &page);
|
||||||
|
if candidates.ambiguous {
|
||||||
|
return Ok(Probe::Ambiguous);
|
||||||
|
}
|
||||||
|
if !page.is_truncated {
|
||||||
|
complete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
advance_version_markers(&mut key_marker, &mut version_marker, &page)?;
|
||||||
|
}
|
||||||
|
if !complete {
|
||||||
|
return Ok(Probe::Ambiguous);
|
||||||
|
}
|
||||||
|
candidates.version_id
|
||||||
|
};
|
||||||
|
let confirmed_versioning = self.remote_bucket_versioning().await?;
|
||||||
|
classify_legacy_transition_state(candidate.as_deref(), initial_versioning, confirmed_versioning)
|
||||||
|
}
|
||||||
|
|
||||||
async fn probe_transition_candidate_for(
|
async fn probe_transition_candidate_for(
|
||||||
&self,
|
&self,
|
||||||
object: &str,
|
object: &str,
|
||||||
@@ -931,3 +1094,32 @@ impl TransitionCandidateReconciler for WarmBackendS3 {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn classify_legacy_transition_state(
|
||||||
|
candidate: Option<&str>,
|
||||||
|
initial: RemoteBucketVersioning,
|
||||||
|
confirmed: RemoteBucketVersioning,
|
||||||
|
) -> Result<super::warm_backend::LegacyTransitionStateProbe, std::io::Error> {
|
||||||
|
use super::warm_backend::LegacyTransitionStateProbe as Probe;
|
||||||
|
if initial != confirmed {
|
||||||
|
return Ok(Probe::Ambiguous);
|
||||||
|
}
|
||||||
|
let Some(version) = candidate else {
|
||||||
|
return Ok(Probe::Missing);
|
||||||
|
};
|
||||||
|
if !version.is_empty() {
|
||||||
|
validate_remote_version_id(version)?;
|
||||||
|
if uuid::Uuid::parse_str(version).is_ok_and(|id| id.is_nil()) {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"legacy tier probe returned a nil version identifier",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(match (confirmed, version) {
|
||||||
|
(RemoteBucketVersioning::Disabled, "" | "null") => Probe::UnversionedPresent,
|
||||||
|
(RemoteBucketVersioning::Disabled, _) | (_, "") => Probe::Ambiguous,
|
||||||
|
(_, "null") => Probe::SuspendedNullPresent,
|
||||||
|
(_, version) => Probe::VersionedPresent(version.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,12 +51,12 @@ use super::super::{
|
|||||||
can_try_inline_data_shards_direct, capacity_scope_from_disks, codec_streaming_rollout_applies, coding,
|
can_try_inline_data_shards_direct, capacity_scope_from_disks, codec_streaming_rollout_applies, coding,
|
||||||
collect_inline_data_shard_fileinfos_by_index_or_reason, current_dirty_generation, debug, disk,
|
collect_inline_data_shard_fileinfos_by_index_or_reason, current_dirty_generation, debug, disk,
|
||||||
file_info_is_valid_for_metadata, get_metadata_slowtail_fault_request, info, inline_erasure_shard_file_offset,
|
file_info_is_valid_for_metadata, get_metadata_slowtail_fault_request, info, inline_erasure_shard_file_offset,
|
||||||
inline_erasure_shard_size, is_err_object_not_found, is_err_version_not_found, is_get_metadata_data_read_early_stop_enabled,
|
inline_erasure_shard_size, is_get_metadata_data_read_early_stop_enabled, is_get_metadata_early_stop_bounded_fanout_enabled,
|
||||||
is_get_metadata_early_stop_bounded_fanout_enabled, is_get_metadata_early_stop_enabled,
|
is_get_metadata_early_stop_enabled, is_get_metadata_non_inline_data_read_early_stop_enabled, is_object_dangling,
|
||||||
is_get_metadata_non_inline_data_read_early_stop_enabled, is_object_dangling, is_version_early_stop_enabled,
|
is_version_early_stop_enabled, issue3031_diag_enabled, join_all, join_errs, log_multipart_write_quorum_failure,
|
||||||
issue3031_diag_enabled, join_all, join_errs, log_multipart_write_quorum_failure, merge_file_meta_versions,
|
merge_file_meta_versions, object_fits_single_block, path_join_buf, record_global_dirty_scope, reduce_read_quorum_errs,
|
||||||
object_fits_single_block, path_join_buf, record_global_dirty_scope, reduce_read_quorum_errs, reduce_write_quorum_errs,
|
reduce_write_quorum_errs, send_heal_request_with_admission, should_prevent_write, to_object_err,
|
||||||
send_heal_request_with_admission, should_prevent_write, to_object_err, try_read_inline_data_shards_direct, warn,
|
try_read_inline_data_shards_direct, warn,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(in crate::set_disk) use super::metadata_quorum::MetadataEarlyStopDecision;
|
pub(in crate::set_disk) use super::metadata_quorum::MetadataEarlyStopDecision;
|
||||||
@@ -3086,21 +3086,61 @@ impl SetDisks {
|
|||||||
let read_quorum = disks.len().div_ceil(2).max(1);
|
let read_quorum = disks.len().div_ceil(2).max(1);
|
||||||
let (raw_fileinfos, errs) = Self::read_all_raw_file_info(&disks, bucket, disk_object.as_str(), false).await;
|
let (raw_fileinfos, errs) = Self::read_all_raw_file_info(&disks, bucket, disk_object.as_str(), false).await;
|
||||||
|
|
||||||
if let Some(err) = reduce_read_quorum_errs(&errs, OBJECT_OP_IGNORED_ERRS, read_quorum) {
|
if let Some(err) = errs
|
||||||
let object_err = to_object_err(err.into(), vec![bucket, object]);
|
.iter()
|
||||||
if is_err_object_not_found(&object_err) || is_err_version_not_found(&object_err) {
|
.flatten()
|
||||||
return Ok(None);
|
.find(|err| !matches!(err, DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::VolumeNotFound))
|
||||||
}
|
{
|
||||||
return Err(object_err);
|
return Err(to_object_err(err.clone().into(), vec![bucket, object]));
|
||||||
|
}
|
||||||
|
// A minority live owner must not disappear behind majority absence.
|
||||||
|
// Only explicit absence on every readable disk proves no ownership.
|
||||||
|
if raw_fileinfos.iter().all(Option::is_none) {
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut shallow_versions = Vec::with_capacity(raw_fileinfos.len());
|
let mut shallow_versions = Vec::with_capacity(raw_fileinfos.len());
|
||||||
|
type TransitionCopy = (FileInfo, Option<crate::services::tier::tier::TierDestinationId>);
|
||||||
|
let mut transition_copies: std::collections::HashMap<Option<Uuid>, Vec<TransitionCopy>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let decode_error = |err| Error::other(format!("exact object versions decode failed for {bucket}/{object}: {err}"));
|
||||||
for raw_fileinfo in raw_fileinfos.into_iter().flatten() {
|
for raw_fileinfo in raw_fileinfos.into_iter().flatten() {
|
||||||
let meta = FileMeta::load(&raw_fileinfo.buf)
|
let meta = FileMeta::load(&raw_fileinfo.buf)
|
||||||
.map_err(|err| Error::other(format!("exact object metadata decode failed for {bucket}/{object}: {err}")))?;
|
.map_err(|err| Error::other(format!("exact object metadata decode failed for {bucket}/{object}: {err}")))?;
|
||||||
|
let versions = meta.get_all_file_info_versions(bucket, object, true).map_err(decode_error)?;
|
||||||
|
for version in versions.versions.into_iter().chain(versions.free_versions) {
|
||||||
|
if version.transition_status != rustfs_filemeta::TRANSITION_COMPLETE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let destination =
|
||||||
|
crate::services::tier::tier::tier_destination_id_from_metadata(&version.metadata).map_err(Error::other)?;
|
||||||
|
transition_copies
|
||||||
|
.entry(version.version_id.filter(|id| !id.is_nil()))
|
||||||
|
.or_default()
|
||||||
|
.push((version, destination));
|
||||||
|
}
|
||||||
shallow_versions.push(meta.versions);
|
shallow_versions.push(meta.versions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exact cleanup/recovery reads must not select a repaired majority
|
||||||
|
// while another physical copy still carries legacy absence. Missing
|
||||||
|
// copies permit deletion retries; an unreadable disk proves nothing.
|
||||||
|
for copies in transition_copies
|
||||||
|
.values()
|
||||||
|
.filter(|copies| copies.iter().any(|(_, destination)| destination.is_some()))
|
||||||
|
{
|
||||||
|
let (first, destination) = &copies[0];
|
||||||
|
if copies.iter().any(|(copy, identity)| {
|
||||||
|
identity != destination
|
||||||
|
|| copy.transition_version_state != first.transition_version_state
|
||||||
|
|| copy.transition_version != first.transition_version
|
||||||
|
|| copy.transition_tier != first.transition_tier
|
||||||
|
|| copy.transitioned_objname != first.transitioned_objname
|
||||||
|
}) {
|
||||||
|
return Err(Error::other("exact transition metadata has not converged across physical copies"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if shallow_versions.len() < read_quorum {
|
if shallow_versions.len() < read_quorum {
|
||||||
return Err(to_object_err(StorageError::ErasureReadQuorum, vec![bucket, object]));
|
return Err(to_object_err(StorageError::ErasureReadQuorum, vec![bucket, object]));
|
||||||
}
|
}
|
||||||
@@ -3117,7 +3157,7 @@ impl SetDisks {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.get_all_file_info_versions(bucket, object, true)
|
.get_all_file_info_versions(bucket, object, true)
|
||||||
.map_err(|err| Error::other(format!("exact object versions decode failed for {bucket}/{object}: {err}")))?;
|
.map_err(decode_error)?;
|
||||||
|
|
||||||
for file_info in file_info_versions
|
for file_info in file_info_versions
|
||||||
.versions
|
.versions
|
||||||
@@ -5815,17 +5855,17 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(disk) = disks[i].as_ref() {
|
if let Some(disk) = disks[i].as_ref() {
|
||||||
let path = path_join_buf(&[prefix, STORAGE_FORMAT_FILE]);
|
// A failed version-only copy owns only its new version.
|
||||||
|
// Removing the whole xl.meta would also erase existing
|
||||||
|
// versions and any concurrently reconciled tier binding.
|
||||||
|
let mut rollback = FileInfo {
|
||||||
|
version_id: files[i].version_id,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
rollback.set_skip_tier_free_version();
|
||||||
revert_futures.push(async move {
|
revert_futures.push(async move {
|
||||||
if let Err(err) = disk
|
if let Err(err) = disk
|
||||||
.delete(
|
.delete_version(bucket, prefix, rollback, false, DeleteOptions::default())
|
||||||
bucket,
|
|
||||||
&path,
|
|
||||||
DeleteOptions {
|
|
||||||
recursive: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("write meta revert err {:?}", err);
|
warn!("write meta revert err {:?}", err);
|
||||||
@@ -12092,7 +12132,9 @@ mod tests {
|
|||||||
let bucket = "write-unique-bucket";
|
let bucket = "write-unique-bucket";
|
||||||
let object = "object";
|
let object = "object";
|
||||||
let (_dir, disk) = read_multiple_test_disk(bucket, &[]).await;
|
let (_dir, disk) = read_multiple_test_disk(bucket, &[]).await;
|
||||||
let files = vec![metadata_test_fileinfo(object), metadata_test_fileinfo(object)];
|
let mut fi = metadata_test_fileinfo(object);
|
||||||
|
fi.mod_time = Some(OffsetDateTime::now_utc());
|
||||||
|
let files = vec![fi.clone(), fi];
|
||||||
|
|
||||||
let result = SetDisks::write_unique_file_info(&[Some(disk.clone()), None], bucket, bucket, object, &files, 2).await;
|
let result = SetDisks::write_unique_file_info(&[Some(disk.clone()), None], bucket, bucket, object, &files, 2).await;
|
||||||
|
|
||||||
@@ -12106,6 +12148,53 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_unique_file_info_rollback_preserves_existing_reconciled_version() {
|
||||||
|
let bucket = "write-unique-existing";
|
||||||
|
let object = "object";
|
||||||
|
let (_dir, disk) = read_multiple_test_disk(bucket, &[]).await;
|
||||||
|
let mut original = metadata_test_fileinfo(object);
|
||||||
|
original.version_id = Some(Uuid::from_u128(1));
|
||||||
|
original.data_dir = Some(Uuid::from_u128(3));
|
||||||
|
original.mod_time = Some(OffsetDateTime::now_utc());
|
||||||
|
original.transition_status = rustfs_filemeta::TRANSITION_COMPLETE.to_string();
|
||||||
|
original.transition_tier = "WARM".to_string();
|
||||||
|
original.transitioned_objname = "remote-original".to_string();
|
||||||
|
original.transition_version_state = rustfs_filemeta::TransitionVersionState::KnownDisabled;
|
||||||
|
rustfs_utils::http::insert_str(
|
||||||
|
&mut original.metadata,
|
||||||
|
rustfs_utils::http::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
||||||
|
"ab".repeat(32),
|
||||||
|
);
|
||||||
|
disk.write_metadata(bucket, bucket, object, original.clone())
|
||||||
|
.await
|
||||||
|
.expect("existing reconciled source");
|
||||||
|
let raw = disk
|
||||||
|
.read_all(bucket, &format!("{object}/{STORAGE_FORMAT_FILE}"))
|
||||||
|
.await
|
||||||
|
.expect("original metadata");
|
||||||
|
let before = FileMeta::load(&raw).unwrap().find_version(original.version_id).unwrap().1;
|
||||||
|
let mut added = original.clone();
|
||||||
|
added.version_id = Some(Uuid::from_u128(2));
|
||||||
|
let result =
|
||||||
|
SetDisks::write_unique_file_info(&[Some(disk.clone()), None], bucket, bucket, object, &[added.clone(), added], 2)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
let raw = disk
|
||||||
|
.read_all(bucket, &format!("{object}/{STORAGE_FORMAT_FILE}"))
|
||||||
|
.await
|
||||||
|
.expect("preserved xl.meta");
|
||||||
|
let after = FileMeta::load(&raw).expect("preserved metadata");
|
||||||
|
assert_eq!(
|
||||||
|
after
|
||||||
|
.find_version(original.version_id)
|
||||||
|
.expect("original source survives rollback")
|
||||||
|
.1,
|
||||||
|
before
|
||||||
|
);
|
||||||
|
assert!(after.find_version(Some(Uuid::from_u128(2))).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_object_meta_handles_empty_metadata_and_missing_quorum() {
|
async fn update_object_meta_handles_empty_metadata_and_missing_quorum() {
|
||||||
let set = io_primitives_test_set(vec![None, None], 1).await;
|
let set = io_primitives_test_set(vec![None, None], 1).await;
|
||||||
|
|||||||
@@ -326,14 +326,15 @@ impl SetDisks {
|
|||||||
let parity_blocks = Self::common_parity(&parities, default_parity_count as i32);
|
let parity_blocks = Self::common_parity(&parities, default_parity_count as i32);
|
||||||
|
|
||||||
if parity_blocks < 0 {
|
if parity_blocks < 0 {
|
||||||
// No parity value reached read quorum. Distinguish two cases:
|
// A consistent layout can require more replies than the initial
|
||||||
// enough disks answered with valid-looking metadata that simply
|
// half-set probe. Reaching that probe alone is not corruption;
|
||||||
// cannot be reconciled (corrupt/foreign entries — retrying cannot
|
// only invalid or conflicting healthy replies establish that.
|
||||||
// help, and heal should see Corrupt, rustfs#5801) versus too few
|
|
||||||
// healthy answers (a genuine quorum condition where retry may
|
|
||||||
// succeed once disks recover).
|
|
||||||
let healthy_replies = errs.iter().filter(|err| err.is_none()).count();
|
let healthy_replies = errs.iter().filter(|err| err.is_none()).count();
|
||||||
if healthy_replies >= expected_rquorum {
|
let consistent_parity = parities
|
||||||
|
.iter()
|
||||||
|
.find(|&&parity| parity >= 0)
|
||||||
|
.filter(|&&parity| parities.iter().filter(|&&candidate| candidate == parity).count() == healthy_replies);
|
||||||
|
if healthy_replies >= expected_rquorum && consistent_parity.is_none() {
|
||||||
error!(
|
error!(
|
||||||
"object_quorum_from_meta: irreconcilable parity across {healthy_replies} healthy replies (corrupt metadata), errs={errs:?}"
|
"object_quorum_from_meta: irreconcilable parity across {healthy_replies} healthy replies (corrupt metadata), errs={errs:?}"
|
||||||
);
|
);
|
||||||
@@ -1652,6 +1653,40 @@ mod tests {
|
|||||||
assert_eq!(err, DiskError::FileCorrupt);
|
assert_eq!(err, DiskError::FileCorrupt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consistent_parity_below_its_data_shard_quorum_is_not_corruption() {
|
||||||
|
for (drive_count, parity) in [(6, 2), (8, 2), (12, 4)] {
|
||||||
|
let data = drive_count - parity;
|
||||||
|
let mut metas = (1..=drive_count)
|
||||||
|
.map(|index| {
|
||||||
|
let mut info = FileInfo::new("bucket/object", data, parity);
|
||||||
|
info.size = 1024;
|
||||||
|
info.erasure.index = index;
|
||||||
|
info
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut errs = vec![Some(DiskError::DiskNotFound); drive_count];
|
||||||
|
errs[..data].fill(None);
|
||||||
|
assert_eq!(
|
||||||
|
SetDisks::object_quorum_from_meta(&metas, &errs, parity).expect("exact data quorum should resolve"),
|
||||||
|
(data as i32, data as i32)
|
||||||
|
);
|
||||||
|
|
||||||
|
errs[data - 1] = Some(DiskError::DiskNotFound);
|
||||||
|
assert_eq!(
|
||||||
|
SetDisks::object_quorum_from_meta(&metas, &errs, parity).expect_err("one fewer shard cannot resolve"),
|
||||||
|
DiskError::ErasureReadQuorum,
|
||||||
|
"layout {drive_count}/{parity} has consistent metadata but insufficient shards"
|
||||||
|
);
|
||||||
|
|
||||||
|
metas[0].erasure.parity_blocks = usize::MAX;
|
||||||
|
assert_eq!(
|
||||||
|
SetDisks::object_quorum_from_meta(&metas, &errs, parity).expect_err("corrupt healthy replies must be rejected"),
|
||||||
|
DiskError::FileCorrupt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Too few healthy replies remains a genuine quorum condition where a
|
/// Too few healthy replies remains a genuine quorum condition where a
|
||||||
/// retry may succeed once disks recover.
|
/// retry may succeed once disks recover.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -865,6 +865,7 @@ pub(crate) use core::io_primitives::{ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, ren
|
|||||||
mod ctx;
|
mod ctx;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod ops;
|
mod ops;
|
||||||
|
pub(crate) use ops::bucket::BucketInfoQuorum;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use ops::hermetic_set_disks_isolated;
|
pub(crate) use ops::hermetic_set_disks_isolated;
|
||||||
@@ -3888,6 +3889,33 @@ pub struct SetDisks {
|
|||||||
>,
|
>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read every physical copy before selecting a version quorum. A minority
|
||||||
|
/// legacy record is still evidence and must not disappear behind a majority
|
||||||
|
/// not-found result. Only an explicit file/volume absence produces `None`;
|
||||||
|
/// an unreadable disk cannot prove that no conflicting copy exists.
|
||||||
|
pub(crate) async fn read_legacy_transition_state_metadata_copies(
|
||||||
|
set: &SetDisks,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
) -> std::result::Result<Vec<Option<Vec<u8>>>, DiskError> {
|
||||||
|
let disk_object = rustfs_utils::path::encode_dir_object(object);
|
||||||
|
let disks = set.get_disks_internal().await;
|
||||||
|
if disks.is_empty() {
|
||||||
|
return Err(DiskError::DiskNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include inline bytes in the generation: a conditional repair preserves
|
||||||
|
// the entire xl.meta, including payloads belonging to other versions.
|
||||||
|
let (copies, errs) = SetDisks::read_all_raw_file_info(&disks, bucket, disk_object.as_str(), true).await;
|
||||||
|
for err in errs.into_iter().flatten() {
|
||||||
|
if !matches!(err, DiskError::FileNotFound | DiskError::FileVersionNotFound | DiskError::VolumeNotFound) {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(copies.into_iter().map(|copy| copy.map(|copy| copy.buf)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
|
// DistributedLock sends the raw ObjectKey to its clients; LockRegistry clones
|
||||||
// each endpoint's canonical Arc, so an exact Arc set identifies the lock domain.
|
// each endpoint's canonical Arc, so an exact Arc set identifies the lock domain.
|
||||||
pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool {
|
pub(crate) fn same_distributed_lock_domain(left: &[Arc<dyn LockClient>], right: &[Arc<dyn LockClient>]) -> bool {
|
||||||
@@ -4236,7 +4264,7 @@ impl SetDisks {
|
|||||||
self.get_object_metadata_cache_generations[generation.index].load(Ordering::Acquire) == generation.value
|
self.get_object_metadata_cache_generations[generation.index].load(Ordering::Acquire) == generation.value
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn invalidate_get_object_metadata_cache(&self, bucket: &str, object: &str) {
|
pub(crate) async fn invalidate_get_object_metadata_cache(&self, bucket: &str, object: &str) {
|
||||||
let hash = self.get_object_metadata_cache_hash(bucket, object);
|
let hash = self.get_object_metadata_cache_hash(bucket, object);
|
||||||
let hash_bytes = hash.to_le_bytes();
|
let hash_bytes = hash.to_le_bytes();
|
||||||
let index = usize::from(u16::from_le_bytes([hash_bytes[0], hash_bytes[1]]) % GET_OBJECT_METADATA_CACHE_FENCE_SHARDS);
|
let index = usize::from(u16::from_le_bytes([hash_bytes[0], hash_bytes[1]]) % GET_OBJECT_METADATA_CACHE_FENCE_SHARDS);
|
||||||
|
|||||||
@@ -21,12 +21,72 @@
|
|||||||
|
|
||||||
use super::super::{
|
use super::super::{
|
||||||
BUCKET_OP_IGNORED_ERRS, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, DiskError, Error, HashMap,
|
BUCKET_OP_IGNORED_ERRS, BucketInfo, BucketOperations, BucketOptions, DeleteBucketOptions, DiskError, Error, HashMap,
|
||||||
MakeBucketOptions, Result, SetDisks, is_reserved_or_invalid_bucket, join_all, reduce_write_quorum_errs,
|
MakeBucketOptions, Result, SetDisks, is_reserved_or_invalid_bucket, join_all, reduce_read_quorum_errs,
|
||||||
|
reduce_write_quorum_errs,
|
||||||
};
|
};
|
||||||
use crate::api::bucket::metadata_sys;
|
use crate::api::bucket::metadata_sys;
|
||||||
use crate::disk::DiskAPI;
|
use crate::disk::DiskAPI;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) enum BucketInfoQuorum {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
}
|
||||||
|
|
||||||
impl SetDisks {
|
impl SetDisks {
|
||||||
|
pub(crate) async fn stat_bucket_with_quorum(&self, bucket: &str, quorum: BucketInfoQuorum) -> Result<BucketInfo> {
|
||||||
|
let disks = self.disk_inventory().await;
|
||||||
|
let disk_count = disks.len();
|
||||||
|
let mut futures = Vec::with_capacity(disk_count);
|
||||||
|
for disk in disks {
|
||||||
|
let bucket = bucket.to_string();
|
||||||
|
futures.push(async move {
|
||||||
|
match disk {
|
||||||
|
Some(disk) => disk.stat_volume(&bucket).await,
|
||||||
|
None => Err(DiskError::DiskNotFound),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = join_all(futures).await;
|
||||||
|
let mut infos = Vec::with_capacity(results.len());
|
||||||
|
let mut errs = Vec::with_capacity(results.len());
|
||||||
|
for result in results {
|
||||||
|
match result {
|
||||||
|
Ok(info) => {
|
||||||
|
infos.push(Some(info));
|
||||||
|
errs.push(None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
infos.push(None);
|
||||||
|
errs.push(Some(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = match quorum {
|
||||||
|
// Bucket mutations use a majority regardless of object storage
|
||||||
|
// class. A namespace read must intersect that majority; object
|
||||||
|
// readers still enforce the persisted layout's data-shard quorum.
|
||||||
|
BucketInfoQuorum::Read => reduce_read_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, disk_count.div_ceil(2).max(1)),
|
||||||
|
BucketInfoQuorum::Write => reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, disk_count / 2 + 1),
|
||||||
|
};
|
||||||
|
if let Some(err) = error {
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
infos
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.next()
|
||||||
|
.map(|info| BucketInfo {
|
||||||
|
name: info.name,
|
||||||
|
created: info.created,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.ok_or(Error::VolumeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec<BucketInfo>, bool)> {
|
pub(crate) async fn list_bucket_for_scanner(&self, _opts: &BucketOptions) -> Result<(Vec<BucketInfo>, bool)> {
|
||||||
let disks = self.disk_inventory().await;
|
let disks = self.disk_inventory().await;
|
||||||
let write_quorum = (disks.len() / 2) + 1;
|
let write_quorum = (disks.len() / 2) + 1;
|
||||||
@@ -131,59 +191,12 @@ impl BucketOperations for SetDisks {
|
|||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
#[tracing::instrument(skip(self))]
|
||||||
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
|
async fn get_bucket_info(&self, bucket: &str, _opts: &BucketOptions) -> Result<BucketInfo> {
|
||||||
let disks = self.disk_inventory().await;
|
let mut info = self.stat_bucket_with_quorum(bucket, BucketInfoQuorum::Write).await?;
|
||||||
let write_quorum = (disks.len() / 2) + 1;
|
|
||||||
|
|
||||||
let mut futures = Vec::with_capacity(disks.len());
|
|
||||||
for disk in disks {
|
|
||||||
let bucket = bucket.to_string();
|
|
||||||
futures.push(async move {
|
|
||||||
match disk {
|
|
||||||
Some(disk) => disk.stat_volume(&bucket).await,
|
|
||||||
None => Err(DiskError::DiskNotFound),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let results = join_all(futures).await;
|
|
||||||
let mut infos = Vec::with_capacity(results.len());
|
|
||||||
let mut errs = Vec::with_capacity(results.len());
|
|
||||||
for result in results {
|
|
||||||
match result {
|
|
||||||
Ok(info) => {
|
|
||||||
infos.push(Some(info));
|
|
||||||
errs.push(None);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
infos.push(None);
|
|
||||||
errs.push(Some(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, write_quorum) {
|
|
||||||
return Err(err.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut versioning = false;
|
|
||||||
let mut object_locking = false;
|
|
||||||
if let Ok(sys) = metadata_sys::get(bucket).await {
|
if let Ok(sys) = metadata_sys::get(bucket).await {
|
||||||
versioning = sys.versioning();
|
info.versioning = sys.versioning();
|
||||||
object_locking = sys.object_locking();
|
info.object_locking = sys.object_locking();
|
||||||
}
|
}
|
||||||
|
Ok(info)
|
||||||
infos
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.next()
|
|
||||||
.map(|info| BucketInfo {
|
|
||||||
name: info.name,
|
|
||||||
created: info.created,
|
|
||||||
versioning,
|
|
||||||
object_locking,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.ok_or(Error::VolumeNotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
#[tracing::instrument(skip(self))]
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
//! contract stays implemented `for SetDisks`, so its associated-type bounds are
|
//! contract stays implemented `for SetDisks`, so its associated-type bounds are
|
||||||
//! unchanged; method bodies are moved verbatim and runtime behavior is the same.
|
//! unchanged; method bodies are moved verbatim and runtime behavior is the same.
|
||||||
|
|
||||||
|
use crate::core::pools::DecommissionCapacityAdmission;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::super::GetObjectMetadataCacheKey;
|
use super::super::GetObjectMetadataCacheKey;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1809,7 +1811,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
let decommission_capacity_guard = if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
Some(
|
Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(
|
||||||
|
&[self.pool_index],
|
||||||
|
DecommissionCapacityAdmission::ExistingMultipart,
|
||||||
|
)
|
||||||
.await?,
|
.await?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -2345,6 +2350,7 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
bucket,
|
bucket,
|
||||||
object,
|
object,
|
||||||
opts.no_lock || object_lock_guard.is_some(),
|
opts.no_lock || object_lock_guard.is_some(),
|
||||||
|
DecommissionCapacityAdmission::ExistingMultipart,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
decommission_object_lock_guard = object_guard;
|
decommission_object_lock_guard = object_guard;
|
||||||
@@ -3110,7 +3116,10 @@ impl crate::storage_api_contracts::multipart::MultipartOperations for SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(
|
||||||
|
&[self.pool_index],
|
||||||
|
DecommissionCapacityAdmission::ExistingMultipart,
|
||||||
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
//! bounds are unchanged, and the impls reach shared primitives through the
|
//! bounds are unchanged, and the impls reach shared primitives through the
|
||||||
//! SetDisks core (io_primitives) via inherent calls.
|
//! SetDisks core (io_primitives) via inherent calls.
|
||||||
|
|
||||||
|
use crate::core::pools::DecommissionCapacityAdmission;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::super::MetadataCacheInvalidationProbe;
|
use super::super::MetadataCacheInvalidationProbe;
|
||||||
use super::super::{
|
use super::super::{
|
||||||
@@ -3905,6 +3907,7 @@ impl SetDisks {
|
|||||||
bucket,
|
bucket,
|
||||||
object,
|
object,
|
||||||
opts.no_lock || object_lock_guard.is_some(),
|
opts.no_lock || object_lock_guard.is_some(),
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
decommission_object_lock_guard = object_guard;
|
decommission_object_lock_guard = object_guard;
|
||||||
@@ -4102,7 +4105,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9206,6 +9209,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
let dest_obj = transaction.remote_object.clone();
|
let dest_obj = transaction.remote_object.clone();
|
||||||
let mut transition_meta = (*oi.user_defined).clone();
|
let mut transition_meta = (*oi.user_defined).clone();
|
||||||
rustfs_utils::http::remove_str(&mut transition_meta, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
|
rustfs_utils::http::remove_str(&mut transition_meta, rustfs_utils::http::SUFFIX_PART_CHECKSUMS);
|
||||||
|
// The tier holds opaque stored bytes. Its metadata must not be treated
|
||||||
|
// as a second object header set: forwarding SSE intent or wrapped DEKs
|
||||||
|
// would request a second encryption pass and disclose local envelope
|
||||||
|
// material to the remote provider.
|
||||||
|
transition_meta.retain(|key, _| !rustfs_utils::http::is_replication_stripped_encryption_key(key));
|
||||||
transition_meta.insert("name".to_string(), object.to_string());
|
transition_meta.insert("name".to_string(), object.to_string());
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
rustfs_utils::http::metadata_compat::insert_str(
|
||||||
&mut transition_meta,
|
&mut transition_meta,
|
||||||
@@ -9762,13 +9770,16 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
part_opts.part_number = Some(part_info.number);
|
part_opts.part_number = Some(part_info.number);
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::InvalidPartSize)?;
|
fail_restore_multipart_at(RestoreMultipartFailurePoint::InvalidPartSize)?;
|
||||||
if part_info.actual_size <= 0 {
|
if part_info.size == 0 {
|
||||||
return Err(Error::other(format!("invalid multipart restore part size {}", part_info.actual_size)));
|
return Err(Error::other(format!("invalid multipart restore stored part size {}", part_info.size)));
|
||||||
}
|
}
|
||||||
|
let stored_part_size = i64::try_from(part_info.size).map_err(|_| {
|
||||||
|
Error::other(format!("multipart restore stored part size exceeds i64: {}", part_info.size))
|
||||||
|
})?;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::RangeOverflow)?;
|
fail_restore_multipart_at(RestoreMultipartFailurePoint::RangeOverflow)?;
|
||||||
let part_end = part_offset
|
let part_end = part_offset
|
||||||
.checked_add(part_info.actual_size - 1)
|
.checked_add(stored_part_size - 1)
|
||||||
.ok_or_else(|| Error::other("multipart restore part range overflow".to_string()))?;
|
.ok_or_else(|| Error::other("multipart restore part range overflow".to_string()))?;
|
||||||
let rs = Some(HTTPRangeSpec {
|
let rs = Some(HTTPRangeSpec {
|
||||||
is_suffix_length: false,
|
is_suffix_length: false,
|
||||||
@@ -9796,13 +9807,19 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::HashReader)?;
|
fail_restore_multipart_at(RestoreMultipartFailurePoint::HashReader)?;
|
||||||
let hash_reader =
|
let hash_reader =
|
||||||
HashReader::from_stream(reader, part_info.actual_size, part_info.actual_size, None, None, false)?;
|
HashReader::from_stream(reader, stored_part_size, part_info.actual_size, None, None, false)?;
|
||||||
let mut p_reader = PutObjReader::new(hash_reader);
|
let mut p_reader = PutObjReader::new(hash_reader);
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fail_restore_multipart_at(RestoreMultipartFailurePoint::PutPart)?;
|
fail_restore_multipart_at(RestoreMultipartFailurePoint::PutPart)?;
|
||||||
|
// `ropts` carries the object's ETag so the single-part copy-back
|
||||||
|
// keeps it (the writer only ever sees stored bytes). A part write
|
||||||
|
// must not inherit that object-level value, or every restored part
|
||||||
|
// would be recorded under the same ETag; each part keeps its own.
|
||||||
|
let mut part_write_opts = ropts.clone();
|
||||||
|
part_write_opts.preserve_etag = Some(part_info.etag.clone()).filter(|etag| !etag.is_empty());
|
||||||
let p_info = self_
|
let p_info = self_
|
||||||
.clone()
|
.clone()
|
||||||
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &ropts)
|
.put_object_part(bucket, object, &res.upload_id, part_info.number, &mut p_reader, &part_write_opts)
|
||||||
.await?;
|
.await?;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
let p_info = if restore_multipart_failure_is(RestoreMultipartFailurePoint::SizeMismatch) {
|
let p_info = if restore_multipart_failure_is(RestoreMultipartFailurePoint::SizeMismatch) {
|
||||||
@@ -9812,7 +9829,7 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
} else {
|
} else {
|
||||||
p_info
|
p_info
|
||||||
};
|
};
|
||||||
if p_info.size as i64 != part_info.actual_size {
|
if p_info.size as i64 != stored_part_size {
|
||||||
return Err(Error::other(ObjectApiError::InvalidObjectState(GenericError {
|
return Err(Error::other(ObjectApiError::InvalidObjectState(GenericError {
|
||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
object: object.to_string(),
|
object: object.to_string(),
|
||||||
@@ -9844,6 +9861,11 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
user_defined: restore_commit_metadata,
|
user_defined: restore_commit_metadata,
|
||||||
no_lock: false,
|
no_lock: false,
|
||||||
decommission_capacity_admission: opts.decommission_capacity_admission.clone(),
|
decommission_capacity_admission: opts.decommission_capacity_admission.clone(),
|
||||||
|
// The composite ETag would otherwise be recomputed from the
|
||||||
|
// parts as they were written back, which for an encrypted or
|
||||||
|
// compressed object digests stored bytes rather than the
|
||||||
|
// object's public ETag.
|
||||||
|
preserve_etag: oi.etag.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
self_
|
self_
|
||||||
@@ -13485,6 +13507,303 @@ mod transition_commit_failure_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// backlog#2368 B5: the tier stores opaque bytes, so the archive request
|
||||||
|
/// must not carry the object's encryption headers. Forwarding them made
|
||||||
|
/// every S3 target reject an SSE-C archive outright, asked the target to
|
||||||
|
/// encrypt an SSE-KMS object a second time under a key id it does not own,
|
||||||
|
/// and handed the wrapped DEK to a third-party provider.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn transition_does_not_forward_encryption_metadata_to_the_tier() {
|
||||||
|
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
let bucket = "transition-encryption-metadata-bucket";
|
||||||
|
let object = "object.bin";
|
||||||
|
for disk in &disk_stores {
|
||||||
|
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryption_metadata = [
|
||||||
|
("x-amz-server-side-encryption", "aws:kms"),
|
||||||
|
("x-amz-server-side-encryption-aws-kms-key-id", "arn:aws:kms:us-east-1:123:key/abc"),
|
||||||
|
("x-amz-server-side-encryption-customer-algorithm", "AES256"),
|
||||||
|
(rustfs_utils::http::INTERNAL_ENCRYPTION_KEY_HEADER, "d3JhcHBlZC1kZWs="),
|
||||||
|
(rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER, "AAAAAAAAAAAAAAAA"),
|
||||||
|
(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER, "AES256"),
|
||||||
|
];
|
||||||
|
let mut user_defined: HashMap<String, String> = encryption_metadata
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
|
||||||
|
.collect();
|
||||||
|
user_defined.insert("x-amz-meta-owner".to_string(), "finance".to_string());
|
||||||
|
|
||||||
|
let mut reader = PutObjReader::from_vec(b"stored bytes the tier keeps opaque ".repeat(64));
|
||||||
|
set_disks
|
||||||
|
.put_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&mut reader,
|
||||||
|
&ObjectOptions {
|
||||||
|
user_defined,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the encrypted source object should be written");
|
||||||
|
let original = set_disks
|
||||||
|
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the source object should be readable");
|
||||||
|
|
||||||
|
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||||
|
let backend = register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
|
||||||
|
set_disks
|
||||||
|
.transition_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
transition: TransitionOptions {
|
||||||
|
status: TRANSITION_PENDING.to_string(),
|
||||||
|
tier: tier_name,
|
||||||
|
etag: original.etag.clone().unwrap_or_default(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
version_id: original.version_id.map(|version| version.to_string()),
|
||||||
|
mod_time: original.mod_time,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the encrypted object should transition");
|
||||||
|
|
||||||
|
let transitioned = set_disks
|
||||||
|
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the transitioned object should be readable");
|
||||||
|
let remote_metadata = backend
|
||||||
|
.metadata(&transitioned.transitioned_object.name)
|
||||||
|
.await
|
||||||
|
.expect("the tier must have received the object");
|
||||||
|
|
||||||
|
for (key, _) in encryption_metadata {
|
||||||
|
assert!(
|
||||||
|
!remote_metadata.keys().any(|stored| stored.eq_ignore_ascii_case(key)),
|
||||||
|
"transition must not forward {key} to the tier: {remote_metadata:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
remote_metadata
|
||||||
|
.iter()
|
||||||
|
.any(|(key, value)| key.eq_ignore_ascii_case("x-amz-meta-owner") && value == "finance"),
|
||||||
|
"ordinary user metadata must still travel to the tier: {remote_metadata:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read-through and restore both resolve encryption locally, so the
|
||||||
|
// stripped keys must survive untouched in the local metadata.
|
||||||
|
for (key, value) in encryption_metadata {
|
||||||
|
assert_eq!(
|
||||||
|
transitioned.user_defined.get(key).map(String::as_str),
|
||||||
|
Some(value),
|
||||||
|
"the local copy must keep {key}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic bytes that do not repeat with a short period, so a slice
|
||||||
|
/// taken at the wrong offset cannot coincidentally compare equal.
|
||||||
|
fn stored_representation_bytes(seed: u32, len: usize) -> Vec<u8> {
|
||||||
|
(0..len)
|
||||||
|
.map(|index| {
|
||||||
|
let mixed = (index as u32).wrapping_add(seed).wrapping_mul(2_654_435_761);
|
||||||
|
(mixed >> 13) as u8
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares two stored representations without dumping megabytes of bytes
|
||||||
|
/// into the failure output.
|
||||||
|
fn assert_stored_representation_eq(actual: &[u8], expected: &[u8], what: &str) {
|
||||||
|
assert_eq!(actual.len(), expected.len(), "{what}: stored length differs");
|
||||||
|
if let Some(offset) = actual.iter().zip(expected).position(|(left, right)| left != right) {
|
||||||
|
panic!(
|
||||||
|
"{what}: stored bytes differ at offset {offset} (found {:#04x}, expected {:#04x})",
|
||||||
|
actual[offset], expected[offset]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_stored_representation(set_disks: &Arc<SetDisks>, bucket: &str, object: &str) -> Vec<u8> {
|
||||||
|
let mut reader = set_disks
|
||||||
|
.get_object_reader(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
None,
|
||||||
|
HeaderMap::new(),
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
raw_data_movement_read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("stored-representation reader should open");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader.stream.read_to_end(&mut body).await.expect("stored body should drain");
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// backlog#2368 B3: the multipart restore loop addresses the tier in STORED
|
||||||
|
/// coordinates. Accumulating each part's PLAINTEXT length instead handed
|
||||||
|
/// every part a misaligned slice of the remote object whose length still
|
||||||
|
/// satisfied the range, the `HashReader` and the completion size check, so
|
||||||
|
/// the copy-back reported success while silently replacing the bytes.
|
||||||
|
///
|
||||||
|
/// The fixture reproduces the encrypted geometry — a stored form LONGER
|
||||||
|
/// than the plaintext it encodes — because that is what keeps a
|
||||||
|
/// plaintext-coordinate range inside the tier object and makes the
|
||||||
|
/// corruption silent rather than a short read.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn multipart_restore_copies_the_stored_representation_back_verbatim() {
|
||||||
|
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks(4).await;
|
||||||
|
let bucket = "restore-multipart-stored-coordinates-bucket";
|
||||||
|
let object = "object.bin";
|
||||||
|
for disk in &disk_stores {
|
||||||
|
disk.make_volume(bucket).await.expect("bucket volume should be created");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The minimum-part-size gate reads the PLAINTEXT length, so part one
|
||||||
|
// clears 5 MiB there while its stored form carries encoding overhead.
|
||||||
|
let part_shapes = [(6 * 1024 * 1024_usize, 9_216_usize), (256 * 1024_usize, 512_usize)];
|
||||||
|
let mut user_defined = HashMap::new();
|
||||||
|
user_defined.insert(rustfs_utils::http::INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "AES256".to_string());
|
||||||
|
user_defined.insert(
|
||||||
|
rustfs_utils::http::INTERNAL_ENCRYPTION_IV_HEADER.to_string(),
|
||||||
|
"AAAAAAAAAAAAAAAA".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let upload = set_disks
|
||||||
|
.new_multipart_upload(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&ObjectOptions {
|
||||||
|
user_defined: user_defined.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("multipart upload should be created");
|
||||||
|
|
||||||
|
let mut uploaded_parts = Vec::new();
|
||||||
|
let mut expected_stored = Vec::new();
|
||||||
|
for (index, (plaintext_len, overhead)) in part_shapes.iter().enumerate() {
|
||||||
|
let stored = stored_representation_bytes(index as u32 * 7 + 1, plaintext_len + overhead);
|
||||||
|
expected_stored.extend_from_slice(&stored);
|
||||||
|
let stored_len = stored.len() as i64;
|
||||||
|
let hash_reader =
|
||||||
|
HashReader::from_stream(std::io::Cursor::new(stored), stored_len, *plaintext_len as i64, None, None, false)
|
||||||
|
.expect("hash reader over the stored representation");
|
||||||
|
let mut reader = PutObjReader::new(hash_reader);
|
||||||
|
let info = set_disks
|
||||||
|
.put_object_part(bucket, object, &upload.upload_id, index + 1, &mut reader, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("stored part should be staged");
|
||||||
|
assert_eq!(info.size as i64, stored_len, "a part is stored in its encoded length");
|
||||||
|
uploaded_parts.push(CompletePart {
|
||||||
|
part_num: info.part_num,
|
||||||
|
etag: info.etag,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let original = set_disks
|
||||||
|
.clone()
|
||||||
|
.complete_multipart_upload(bucket, object, &upload.upload_id, uploaded_parts, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("source multipart upload should complete");
|
||||||
|
let original_parts: Vec<(usize, usize, i64, String)> = original
|
||||||
|
.parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
|
||||||
|
.collect();
|
||||||
|
for (_, size, actual_size, _) in &original_parts {
|
||||||
|
assert!(
|
||||||
|
*size as i64 > *actual_size,
|
||||||
|
"the fixture must keep the two coordinate systems apart: stored {size} vs plaintext {actual_size}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let stored_before = read_stored_representation(&set_disks, bucket, object).await;
|
||||||
|
assert_stored_representation_eq(&stored_before, &expected_stored, "the fixture must store its encoded bytes verbatim");
|
||||||
|
|
||||||
|
let tier_name = format!("COLDTIER{}", &Uuid::new_v4().simple().to_string()[..8]).to_uppercase();
|
||||||
|
register_mock_tier(&runtime_sources::global_tier_config_mgr(), &tier_name).await;
|
||||||
|
set_disks
|
||||||
|
.transition_object(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
transition: TransitionOptions {
|
||||||
|
status: TRANSITION_PENDING.to_string(),
|
||||||
|
tier: tier_name,
|
||||||
|
etag: original.etag.clone().unwrap_or_default(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
version_id: original.version_id.map(|version| version.to_string()),
|
||||||
|
mod_time: original.mod_time,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("multipart source should transition before restore");
|
||||||
|
|
||||||
|
let operation_id = Uuid::new_v4();
|
||||||
|
set_disks
|
||||||
|
.put_object_metadata(
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
&ObjectOptions {
|
||||||
|
eval_metadata: Some(restore_metadata(operation_id, true)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the restore generation should be installed");
|
||||||
|
let mut restore_opts = ObjectOptions::default();
|
||||||
|
restore_opts.transition.restore_request.days = Some(1);
|
||||||
|
restore_opts.user_defined = restore_operation_id_metadata(operation_id);
|
||||||
|
set_disks
|
||||||
|
.clone()
|
||||||
|
.restore_transitioned_object(bucket, object, &restore_opts)
|
||||||
|
.await
|
||||||
|
.expect("multipart restore should complete");
|
||||||
|
|
||||||
|
let stored_after = read_stored_representation(&set_disks, bucket, object).await;
|
||||||
|
assert_stored_representation_eq(
|
||||||
|
&stored_after,
|
||||||
|
&expected_stored,
|
||||||
|
"a multipart restore must copy the stored representation back verbatim",
|
||||||
|
);
|
||||||
|
|
||||||
|
let restored = set_disks
|
||||||
|
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the restored object should be readable");
|
||||||
|
let restored_parts: Vec<(usize, usize, i64, String)> = restored
|
||||||
|
.parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| (part.number, part.size, part.actual_size, part.etag.clone()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
restored_parts, original_parts,
|
||||||
|
"restore must rebuild the same part layout, sizes and part ETags"
|
||||||
|
);
|
||||||
|
assert_eq!(restored.size, original.size, "restore must keep the stored object size");
|
||||||
|
// backlog#2369 P7.1: the copy-back digests stored bytes, so the object's
|
||||||
|
// public ETag has to be carried over rather than recomputed.
|
||||||
|
assert_eq!(restored.etag, original.etag, "restore must preserve the object ETag");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn restore_failure_after_snapshot_cleans_exact_generation_and_returns_primary_error() {
|
async fn restore_failure_after_snapshot_cleans_exact_generation_and_returns_primary_error() {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use super::{
|
|||||||
UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata,
|
UpdateMetadataOpts, Uuid, X_AMZ_RESTORE, get_raw_etag, restore_operation_id_from_metadata,
|
||||||
};
|
};
|
||||||
use crate::bucket::lifecycle::lifecycle;
|
use crate::bucket::lifecycle::lifecycle;
|
||||||
|
use crate::core::pools::DecommissionCapacityAdmission;
|
||||||
use rustfs_filemeta::RestoreStatusOps;
|
use rustfs_filemeta::RestoreStatusOps;
|
||||||
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
|
use rustfs_utils::http::headers::{AMZ_RESTORE_EXPIRY_DAYS, AMZ_RESTORE_REQUEST_DATE};
|
||||||
use s3s::dto::{RestoreStatus, Timestamp};
|
use s3s::dto::{RestoreStatus, Timestamp};
|
||||||
@@ -160,7 +161,13 @@ impl SetDisks {
|
|||||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
.acquire_external_decommission_commit_guards(
|
||||||
|
self.pool_index,
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
opts.no_lock,
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
(None, false, None)
|
(None, false, None)
|
||||||
@@ -178,7 +185,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -264,7 +271,13 @@ impl SetDisks {
|
|||||||
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
let (decommission_object_lock_guard, decommission_target_lock_covered, mut decommission_capacity_guard) =
|
||||||
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
if let Some(store) = opts.decommission_capacity_admission.as_ref() {
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_commit_guards(self.pool_index, bucket, object, opts.no_lock)
|
.acquire_external_decommission_commit_guards(
|
||||||
|
self.pool_index,
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
opts.no_lock,
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
(None, false, None)
|
(None, false, None)
|
||||||
@@ -282,7 +295,7 @@ impl SetDisks {
|
|||||||
{
|
{
|
||||||
decommission_capacity_guard = Some(
|
decommission_capacity_guard = Some(
|
||||||
store
|
store
|
||||||
.acquire_external_decommission_capacity_fence(&[self.pool_index], "mutation")
|
.acquire_external_decommission_capacity_fence(&[self.pool_index], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use crate::bucket::{
|
|||||||
};
|
};
|
||||||
use crate::error::is_err_bucket_not_found;
|
use crate::error::is_err_bucket_not_found;
|
||||||
use crate::runtime::sources as runtime_sources;
|
use crate::runtime::sources as runtime_sources;
|
||||||
use crate::set_disk::get_lock_acquire_timeout;
|
use crate::set_disk::{BucketInfoQuorum, get_lock_acquire_timeout};
|
||||||
use crate::storage_api_contracts::bucket::{BUCKET_LIFECYCLE_LOCK_OBJECT, SRBucketDeleteOp};
|
use crate::storage_api_contracts::bucket::{BUCKET_LIFECYCLE_LOCK_OBJECT, SRBucketDeleteOp};
|
||||||
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
|
||||||
use futures::stream::{self, StreamExt};
|
use futures::stream::{self, StreamExt};
|
||||||
@@ -770,19 +770,57 @@ impl ECStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prove a live bucket generation before repairing missing expansion volumes.
|
||||||
|
/// Unlike request validation, repair only needs one erasure set to confirm
|
||||||
|
/// existence; an incomplete expansion set is precisely what repair fixes.
|
||||||
|
/// Callers must hold the bucket namespace lock through the subsequent heal.
|
||||||
|
pub(crate) async fn bucket_exists_for_heal(&self, bucket: &str) -> Result<bool> {
|
||||||
|
let results = futures::future::join_all(
|
||||||
|
self.bucket_sets()
|
||||||
|
.map(|(_, _, set)| async move { set.get_bucket_info(bucket, &BucketOptions::default()).await }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut first_error = None;
|
||||||
|
for result in results {
|
||||||
|
match result {
|
||||||
|
Ok(_) => return Ok(true),
|
||||||
|
Err(err) if is_err_strict_volume_not_found(&err) => {}
|
||||||
|
Err(err) if first_error.is_none() => first_error = Some(err),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match first_error {
|
||||||
|
Some(err) => Err(err),
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub(crate) async fn get_bucket_info_from_sets(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
|
pub(crate) async fn get_bucket_info_from_sets(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
|
||||||
|
self.get_bucket_info_from_sets_with_quorum(bucket, opts, BucketInfoQuorum::Write)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_bucket_info_from_sets_with_quorum(
|
||||||
|
&self,
|
||||||
|
bucket: &str,
|
||||||
|
opts: &BucketOptions,
|
||||||
|
quorum: BucketInfoQuorum,
|
||||||
|
) -> Result<BucketInfo> {
|
||||||
// One host may participate in several pools after expansion. Resolve the
|
// One host may participate in several pools after expansion. Resolve the
|
||||||
// namespace against each erasure set so disks from different pools can
|
// namespace against each erasure set so disks from different pools can
|
||||||
// never be combined into one bucket quorum.
|
// never be combined into one bucket quorum.
|
||||||
// Bucket validation is request-path IO. Keep the previous peer fanout's
|
// Bucket validation is request-path IO. Keep the previous peer fanout's
|
||||||
// latency shape by probing every set concurrently; scanner listings use
|
// latency shape by probing every set concurrently; scanner listings use
|
||||||
// a separate bounded path below because they run continuously.
|
// a separate bounded path below because they run continuously.
|
||||||
let mut scoped_results =
|
let mut scoped_results = futures::future::join_all(self.bucket_sets().map(|(pool_index, set_index, set)| async move {
|
||||||
futures::future::join_all(self.bucket_sets().map(|(pool_index, set_index, set)| async move {
|
let result = match quorum {
|
||||||
(pool_index, set_index, set.get_bucket_info(bucket, opts).await)
|
BucketInfoQuorum::Read => set.stat_bucket_with_quorum(bucket, quorum).await,
|
||||||
}))
|
BucketInfoQuorum::Write => set.get_bucket_info(bucket, opts).await,
|
||||||
.await;
|
};
|
||||||
|
(pool_index, set_index, result)
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
scoped_results.sort_unstable_by_key(|(pool_index, set_index, _)| (*pool_index, *set_index));
|
scoped_results.sort_unstable_by_key(|(pool_index, set_index, _)| (*pool_index, *set_index));
|
||||||
|
|
||||||
let mut first_info = None;
|
let mut first_info = None;
|
||||||
@@ -806,7 +844,11 @@ impl ECStore {
|
|||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub(super) async fn handle_get_bucket_info(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
|
pub(super) async fn handle_get_bucket_info(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
|
||||||
let mut info = self.get_bucket_info_from_sets(bucket, opts).await?;
|
let mut info = match self.get_bucket_info_from_sets(bucket, opts).await {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(Error::ErasureWriteQuorum) => return self.get_bucket_info_at_read_quorum(bucket, opts).await,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
if let Ok(sys) = metadata_sys::get_in(&self.ctx, bucket).await {
|
if let Ok(sys) = metadata_sys::get_in(&self.ctx, bucket).await {
|
||||||
if should_override_created_from_metadata(sys.created) {
|
if should_override_created_from_metadata(sys.created) {
|
||||||
@@ -819,6 +861,35 @@ impl ECStore {
|
|||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_bucket_info_at_read_quorum(&self, bucket: &str, opts: &BucketOptions) -> Result<BucketInfo> {
|
||||||
|
// Lock order: bucket lifecycle -> internal metadata object read locks.
|
||||||
|
// Keep create/delete from changing the namespace while a read quorum
|
||||||
|
// confirms both physical presence and persisted bucket metadata.
|
||||||
|
let guard = self.acquire_bucket_lifecycle_read_lock(bucket).await?;
|
||||||
|
await_bucket_namespace_operation(Some(&guard), bucket, "bucket read quorum validation", async {
|
||||||
|
let mut info = self
|
||||||
|
.get_bucket_info_from_sets_with_quorum(bucket, opts, BucketInfoQuorum::Read)
|
||||||
|
.await?;
|
||||||
|
let (metadata, persisted) = metadata_sys::get_config_from_disk_with_presence_in(&self.ctx, bucket).await?;
|
||||||
|
if !persisted {
|
||||||
|
// A minority of directories left by failed creation is not an
|
||||||
|
// authoritative bucket. Never turn fabricated defaults into
|
||||||
|
// permission to serve degraded reads.
|
||||||
|
return Err(Error::ErasureReadQuorum);
|
||||||
|
}
|
||||||
|
if metadata.name != bucket {
|
||||||
|
return Err(Error::FileCorrupt);
|
||||||
|
}
|
||||||
|
if should_override_created_from_metadata(metadata.created) {
|
||||||
|
info.created = Some(metadata.created);
|
||||||
|
}
|
||||||
|
info.versioning = metadata.versioning();
|
||||||
|
info.object_locking = metadata.object_locking();
|
||||||
|
Ok(info)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
#[instrument(skip(self))]
|
||||||
pub(super) async fn handle_list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
|
pub(super) async fn handle_list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
|
||||||
// TODO(backlog): support cached bucket listing via opts.cached
|
// TODO(backlog): support cached bucket listing via opts.cached
|
||||||
@@ -1049,7 +1120,7 @@ mod tests {
|
|||||||
run_physical_bucket_deletion, scan_metadata_less_residue, scan_metadata_less_residue_with_budget,
|
run_physical_bucket_deletion, scan_metadata_less_residue, scan_metadata_less_residue_with_budget,
|
||||||
should_override_created_from_metadata, validate_table_bucket_delete_allowed,
|
should_override_created_from_metadata, validate_table_bucket_delete_allowed,
|
||||||
};
|
};
|
||||||
use crate::bucket::metadata::table_bucket_catalog_metadata_prefix;
|
use crate::bucket::metadata::{BucketMetadata, table_bucket_catalog_metadata_prefix};
|
||||||
use crate::bucket::metadata_sys;
|
use crate::bucket::metadata_sys;
|
||||||
use crate::cluster::rpc::peer_s3_client::install_delete_bucket_empty_scan_barrier;
|
use crate::cluster::rpc::peer_s3_client::install_delete_bucket_empty_scan_barrier;
|
||||||
use crate::disk::{BUCKET_META_PREFIX, DiskAPI, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE};
|
use crate::disk::{BUCKET_META_PREFIX, DiskAPI, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE};
|
||||||
@@ -1076,6 +1147,7 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
use tokio::sync::{Notify, OnceCell};
|
use tokio::sync::{Notify, OnceCell};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -1359,11 +1431,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_multi_pool_bucket_test_env() -> (tempfile::TempDir, Arc<ECStore>) {
|
async fn setup_multi_pool_bucket_test_env() -> (tempfile::TempDir, Arc<ECStore>) {
|
||||||
|
setup_bucket_quorum_test_env(&[4, 4], None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_bucket_quorum_test_env(
|
||||||
|
drives_per_pool: &[usize],
|
||||||
|
standard_parity: Option<usize>,
|
||||||
|
) -> (tempfile::TempDir, Arc<ECStore>) {
|
||||||
let temp_dir = tempfile::tempdir().expect("multi-pool bucket test directory should be created");
|
let temp_dir = tempfile::tempdir().expect("multi-pool bucket test directory should be created");
|
||||||
let mut pools = Vec::new();
|
let mut pools = Vec::new();
|
||||||
for pool_index in 0..2 {
|
for (pool_index, &drive_count) in drives_per_pool.iter().enumerate() {
|
||||||
let mut endpoints = Vec::new();
|
let mut endpoints = Vec::new();
|
||||||
for disk_index in 0..4 {
|
for disk_index in 0..drive_count {
|
||||||
let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}"));
|
let disk_path = temp_dir.path().join(format!("pool{pool_index}-disk{disk_index}"));
|
||||||
tokio::fs::create_dir_all(&disk_path)
|
tokio::fs::create_dir_all(&disk_path)
|
||||||
.await
|
.await
|
||||||
@@ -1378,7 +1457,7 @@ mod tests {
|
|||||||
pools.push(PoolEndpoints {
|
pools.push(PoolEndpoints {
|
||||||
legacy: false,
|
legacy: false,
|
||||||
set_count: 1,
|
set_count: 1,
|
||||||
drives_per_set: 4,
|
drives_per_set: drive_count,
|
||||||
endpoints: Endpoints::from(endpoints),
|
endpoints: Endpoints::from(endpoints),
|
||||||
cmd_line: format!("bucket-test-pool-{pool_index}"),
|
cmd_line: format!("bucket-test-pool-{pool_index}"),
|
||||||
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
platform: format!("OS: {} | Arch: {}", std::env::consts::OS, std::env::consts::ARCH),
|
||||||
@@ -1399,9 +1478,12 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("multi-pool ECStore should initialize");
|
.expect("multi-pool ECStore should initialize");
|
||||||
let storage_class =
|
let mut storage_class_kvs = rustfs_config::server_config::KVS::new();
|
||||||
crate::config::storageclass::lookup_config_for_pools_without_env(&rustfs_config::server_config::KVS::new(), &[4, 4])
|
if let Some(parity) = standard_parity {
|
||||||
.expect("multi-pool storage class should match both four-disk pools");
|
storage_class_kvs.insert(crate::config::storageclass::CLASS_STANDARD.to_string(), format!("EC:{parity}"));
|
||||||
|
}
|
||||||
|
let storage_class = crate::config::storageclass::lookup_config_for_pools_without_env(&storage_class_kvs, drives_per_pool)
|
||||||
|
.expect("storage class should match every test erasure set");
|
||||||
for pool in &ecstore.pools {
|
for pool in &ecstore.pools {
|
||||||
for set in &pool.disk_set {
|
for set in &pool.disk_set {
|
||||||
set.set_test_storage_class_config(storage_class.clone());
|
set.set_test_storage_class_config(storage_class.clone());
|
||||||
@@ -2050,6 +2132,98 @@ mod tests {
|
|||||||
.expect("metadata initialization should recreate the bucket volume in the new pool");
|
.expect("metadata initialization should recreate the bucket volume in the new pool");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn bucket_metadata_init_repairs_half_created_expansion_pool() {
|
||||||
|
// Check both pool orders: an incomplete set must not hide a later
|
||||||
|
// complete set, and a complete set must not weaken request validation.
|
||||||
|
for complete_pool in 0..2 {
|
||||||
|
let (temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await;
|
||||||
|
let bucket = format!("partial-expansion-{}", Uuid::new_v4().simple());
|
||||||
|
for pool_index in 0..2 {
|
||||||
|
let present_disks = if pool_index == complete_pool { 4 } else { 2 };
|
||||||
|
for disk_index in 0..present_disks {
|
||||||
|
tokio::fs::create_dir(
|
||||||
|
temp_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("pool{pool_index}-disk{disk_index}"))
|
||||||
|
.join(&bucket),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture bucket volume should be created");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
ecstore
|
||||||
|
.get_bucket_info_from_sets(&bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("request validation must reject a half-created expansion set"),
|
||||||
|
StorageError::ErasureWriteQuorum
|
||||||
|
);
|
||||||
|
|
||||||
|
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), vec![bucket.clone()]).await;
|
||||||
|
|
||||||
|
for pool_index in 0..2 {
|
||||||
|
for disk_index in 0..4 {
|
||||||
|
assert!(
|
||||||
|
temp_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("pool{pool_index}-disk{disk_index}"))
|
||||||
|
.join(&bucket)
|
||||||
|
.is_dir(),
|
||||||
|
"metadata initialization must heal every missing expansion volume"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ecstore
|
||||||
|
.get_bucket_info_from_sets(&bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("strict request validation should succeed after volume repair");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn bucket_metadata_init_does_not_combine_partial_set_evidence() {
|
||||||
|
let (temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await;
|
||||||
|
let bucket = format!("no-quorum-expansion-{}", Uuid::new_v4().simple());
|
||||||
|
for pool_index in 0..2 {
|
||||||
|
for disk_index in 0..2 {
|
||||||
|
tokio::fs::create_dir(
|
||||||
|
temp_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("pool{pool_index}-disk{disk_index}"))
|
||||||
|
.join(&bucket),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fixture bucket volume should be created");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
ecstore
|
||||||
|
.bucket_exists_for_heal(&bucket)
|
||||||
|
.await
|
||||||
|
.expect_err("repair must require a complete quorum within one set"),
|
||||||
|
StorageError::ErasureWriteQuorum
|
||||||
|
);
|
||||||
|
|
||||||
|
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), vec![bucket.clone()]).await;
|
||||||
|
|
||||||
|
for pool_index in 0..2 {
|
||||||
|
for disk_index in 0..4 {
|
||||||
|
assert_eq!(
|
||||||
|
temp_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("pool{pool_index}-disk{disk_index}"))
|
||||||
|
.join(&bucket)
|
||||||
|
.is_dir(),
|
||||||
|
disk_index < 2,
|
||||||
|
"unproven bucket generations must not recreate missing volumes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn bucket_metadata_init_does_not_recreate_stale_bucket_name() {
|
async fn bucket_metadata_init_does_not_recreate_stale_bucket_name() {
|
||||||
@@ -2067,6 +2241,218 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn bucket_info_read_quorum_tracks_erasure_layout() {
|
||||||
|
for (drive_count, parity) in [(2, 1), (3, 1), (4, 2), (5, 2), (6, 3), (8, 4), (6, 2), (12, 6)] {
|
||||||
|
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[drive_count], Some(parity)).await;
|
||||||
|
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
let bucket = format!("read-quorum-{drive_count}-{parity}");
|
||||||
|
let object = "uncached-object";
|
||||||
|
let body = b"erasure read quorum must follow the persisted layout".repeat(32_768);
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("healthy namespace should accept bucket creation");
|
||||||
|
store
|
||||||
|
.put_object(&bucket, object, &mut PutObjReader::from_vec(body.clone()), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("healthy erasure set should accept the seed object");
|
||||||
|
let set = &store.pools[0].disk_set[0];
|
||||||
|
let lock = set
|
||||||
|
.new_ns_lock(&bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("seed namespace lock should resolve");
|
||||||
|
drop(
|
||||||
|
lock.get_write_lock(Duration::from_secs(30))
|
||||||
|
.await
|
||||||
|
.expect("seed physical fanout must finish before taking disks offline"),
|
||||||
|
);
|
||||||
|
if (drive_count, parity) == (6, 3) {
|
||||||
|
let mut kvs = rustfs_config::server_config::KVS::new();
|
||||||
|
kvs.insert(crate::config::storageclass::CLASS_STANDARD.to_string(), "EC:2".to_string());
|
||||||
|
set.set_test_storage_class_config(
|
||||||
|
crate::config::storageclass::lookup_config_for_pools_without_env(&kvs, &[drive_count])
|
||||||
|
.expect("a later storage-class change must not raise old objects' read quorum"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let offline_indexes = (0..parity).collect::<Vec<_>>();
|
||||||
|
let offline = take_set_disks_offline(&store, set, &offline_indexes).await;
|
||||||
|
let info = store
|
||||||
|
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("bucket validation must admit the object's exact read quorum");
|
||||||
|
assert_eq!(info.name, bucket);
|
||||||
|
|
||||||
|
let mut reader = store
|
||||||
|
.get_object_reader(&bucket, object, None, Default::default(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the persisted layout should remain readable at its exact data-shard quorum");
|
||||||
|
let mut restored = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut restored)
|
||||||
|
.await
|
||||||
|
.expect("quorum read should reconstruct the body");
|
||||||
|
assert_eq!(restored, body, "layout {drive_count}/{parity} must retain exact object contents");
|
||||||
|
drop(reader);
|
||||||
|
|
||||||
|
if drive_count - parity == drive_count / 2 {
|
||||||
|
let error = store
|
||||||
|
.get_bucket_info_from_sets(&bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("bucket mutations must retain their majority namespace check");
|
||||||
|
assert_eq!(error, StorageError::ErasureWriteQuorum);
|
||||||
|
}
|
||||||
|
|
||||||
|
let below_quorum = take_set_disks_offline(&store, set, &[parity]).await;
|
||||||
|
let read = store
|
||||||
|
.get_object_reader(&bucket, object, None, Default::default(), &ObjectOptions::default())
|
||||||
|
.await;
|
||||||
|
match read {
|
||||||
|
Ok(mut reader) => assert!(
|
||||||
|
reader.stream.read_to_end(&mut Vec::new()).await.is_err(),
|
||||||
|
"layout {drive_count}/{parity} must reject fewer than its data-shard quorum"
|
||||||
|
),
|
||||||
|
Err(error) => assert!(
|
||||||
|
matches!(error, StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _)),
|
||||||
|
"a missing shard must report read quorum loss, got {error}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
restore_set_disks(&store, set, below_quorum).await;
|
||||||
|
restore_set_disks(&store, set, offline).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn bucket_info_read_quorum_is_scoped_to_each_erasure_set() {
|
||||||
|
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4, 6], None).await;
|
||||||
|
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
let bucket = "read-quorum-mixed-pools";
|
||||||
|
store
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("healthy pools should accept bucket creation");
|
||||||
|
|
||||||
|
let first_set = &store.pools[0].disk_set[0];
|
||||||
|
let second_set = &store.pools[1].disk_set[0];
|
||||||
|
let first_offline = take_set_disks_offline(&store, first_set, &[0, 1]).await;
|
||||||
|
let second_offline = take_set_disks_offline(&store, second_set, &[0, 1, 2]).await;
|
||||||
|
store
|
||||||
|
.get_bucket_info(bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("each set independently satisfies its namespace read quorum");
|
||||||
|
|
||||||
|
for (set, extra_disk) in [(first_set, 2), (second_set, 3)] {
|
||||||
|
let extra_offline = take_set_disks_offline(&store, set, &[extra_disk]).await;
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_bucket_info(bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("another pool must not subsidize a set below its read quorum"),
|
||||||
|
StorageError::ErasureReadQuorum
|
||||||
|
);
|
||||||
|
restore_set_disks(&store, set, extra_offline).await;
|
||||||
|
}
|
||||||
|
restore_set_disks(&store, first_set, first_offline).await;
|
||||||
|
restore_set_disks(&store, second_set, second_offline).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn bucket_info_read_quorum_requires_authoritative_metadata() {
|
||||||
|
for state in ["missing", "corrupt", "foreign", "incarnation"] {
|
||||||
|
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4], None).await;
|
||||||
|
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
let bucket = format!("read-quorum-{state}-metadata");
|
||||||
|
let mut metadata = if state == "missing" {
|
||||||
|
store
|
||||||
|
.make_bucket_on_sets(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("simulate directories left before bucket metadata is published");
|
||||||
|
BucketMetadata::new(&bucket)
|
||||||
|
} else {
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("healthy bucket should publish metadata");
|
||||||
|
metadata_sys::get_in(&store.ctx, &bucket)
|
||||||
|
.await
|
||||||
|
.expect("seed metadata should be cached")
|
||||||
|
.as_ref()
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
let path = metadata.save_file_path();
|
||||||
|
match state {
|
||||||
|
"corrupt" => crate::config::com::save_config(store.clone(), &path, b"corrupt".to_vec())
|
||||||
|
.await
|
||||||
|
.expect("persist corrupt metadata while the cached copy remains valid"),
|
||||||
|
"foreign" => {
|
||||||
|
metadata.name = "different-bucket".to_string();
|
||||||
|
let mut encoded = vec![1, 0, 1, 0];
|
||||||
|
encoded.extend(metadata.marshal_msg().expect("foreign metadata should encode"));
|
||||||
|
crate::config::com::save_config(store.clone(), &path, encoded)
|
||||||
|
.await
|
||||||
|
.expect("persist metadata for a different bucket at the requested path");
|
||||||
|
}
|
||||||
|
"incarnation" => crate::bucket::metadata::save_bucket_incarnation(store.clone(), &bucket, Uuid::new_v4())
|
||||||
|
.await
|
||||||
|
.expect("persist a different bucket generation"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let set = &store.pools[0].disk_set[0];
|
||||||
|
let offline = take_set_disks_offline(&store, set, &[0, 1]).await;
|
||||||
|
let error = store
|
||||||
|
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("read admission must not trust residual directories or cached metadata");
|
||||||
|
match state {
|
||||||
|
"missing" => assert_eq!(error, StorageError::ErasureReadQuorum),
|
||||||
|
"foreign" => assert_eq!(error, StorageError::FileCorrupt),
|
||||||
|
"incarnation" => assert!(error.to_string().contains("sidecar does not match bucket metadata")),
|
||||||
|
"corrupt" => assert!(error.to_string().contains("format invalid"), "unexpected corruption error: {error}"),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
restore_set_disks(&store, set, offline).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn bucket_info_read_quorum_accepts_persisted_legacy_metadata() {
|
||||||
|
let (_temp_dir, store) = setup_bucket_quorum_test_env(&[4], None).await;
|
||||||
|
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
let bucket = "interop";
|
||||||
|
store
|
||||||
|
.make_bucket_on_sets(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("legacy bucket directories should exist");
|
||||||
|
let hex = include_str!("../../tests/fixtures/minio/bucket_metadata.blob.hex")
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<String>();
|
||||||
|
let body = (0..hex.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|index| u8::from_str_radix(&hex[index..index + 2], 16).expect("pinned MinIO metadata fixture"))
|
||||||
|
.collect();
|
||||||
|
crate::config::com::save_config(store.clone(), &BucketMetadata::new(bucket).save_file_path(), body)
|
||||||
|
.await
|
||||||
|
.expect("legacy metadata should be persisted without an incarnation sidecar");
|
||||||
|
|
||||||
|
let set = &store.pools[0].disk_set[0];
|
||||||
|
let offline = take_set_disks_offline(&store, set, &[0, 1]).await;
|
||||||
|
let info = store
|
||||||
|
.get_bucket_info(bucket, &BucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("persisted MinIO metadata should authorize reads at the namespace read quorum");
|
||||||
|
assert_eq!(info.name, bucket);
|
||||||
|
assert!(info.versioning);
|
||||||
|
assert!(info.object_locking);
|
||||||
|
restore_set_disks(&store, set, offline).await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn bucket_namespace_reads_report_missing_when_every_set_is_absent() {
|
async fn bucket_namespace_reads_report_missing_when_every_set_is_absent() {
|
||||||
@@ -2090,6 +2476,7 @@ mod tests {
|
|||||||
#[serial]
|
#[serial]
|
||||||
async fn bucket_namespace_reads_fail_closed_when_any_set_loses_quorum() {
|
async fn bucket_namespace_reads_fail_closed_when_any_set_loses_quorum() {
|
||||||
let (_temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await;
|
let (_temp_dir, ecstore) = setup_multi_pool_bucket_test_env().await;
|
||||||
|
metadata_sys::init_bucket_metadata_sys(ecstore.clone(), Vec::new()).await;
|
||||||
let bucket = format!("degraded-expansion-{}", Uuid::new_v4().simple());
|
let bucket = format!("degraded-expansion-{}", Uuid::new_v4().simple());
|
||||||
ecstore.pools[0].disk_set[0]
|
ecstore.pools[0].disk_set[0]
|
||||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
@@ -2097,6 +2484,7 @@ mod tests {
|
|||||||
.expect("bucket should be created in the original pool only");
|
.expect("bucket should be created in the original pool only");
|
||||||
ecstore.pools[1].disk_set[0].disks.write().await[0] = None;
|
ecstore.pools[1].disk_set[0].disks.write().await[0] = None;
|
||||||
ecstore.pools[1].disk_set[0].disks.write().await[1] = None;
|
ecstore.pools[1].disk_set[0].disks.write().await[1] = None;
|
||||||
|
ecstore.pools[1].disk_set[0].disks.write().await[2] = None;
|
||||||
|
|
||||||
let list_err = ecstore
|
let list_err = ecstore
|
||||||
.list_bucket(&BucketOptions::default())
|
.list_bucket(&BucketOptions::default())
|
||||||
@@ -2108,7 +2496,7 @@ mod tests {
|
|||||||
.get_bucket_info(&bucket, &BucketOptions::default())
|
.get_bucket_info(&bucket, &BucketOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect_err("bucket validation must fail when an expansion pool is unavailable");
|
.expect_err("bucket validation must fail when an expansion pool is unavailable");
|
||||||
assert_eq!(info_err, StorageError::ErasureWriteQuorum);
|
assert_eq!(info_err, StorageError::ErasureReadQuorum);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -671,9 +671,9 @@ mod tests {
|
|||||||
use crate::cluster::rpc::PeerS3Client;
|
use crate::cluster::rpc::PeerS3Client;
|
||||||
use crate::config::com::{delete_config, read_config_no_lock_preserve_empty_with_metadata, save_config};
|
use crate::config::com::{delete_config, read_config_no_lock_preserve_empty_with_metadata, save_config};
|
||||||
use crate::core::pools::{
|
use crate::core::pools::{
|
||||||
DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME,
|
DecommissionCapacityAdmission, DecommissionCapacityLockOrderBarrier, DecommissionErasureLayout,
|
||||||
PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus, initialized_pool_meta_identity_for_test,
|
DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, PoolDecommissionInfo, PoolMetaReplicaState, PoolStatus,
|
||||||
set_decommission_capacity_info_overrides_for_test,
|
initialized_pool_meta_identity_for_test, set_decommission_capacity_info_overrides_for_test,
|
||||||
};
|
};
|
||||||
use crate::core::sets::HealFormatAfterSaveBarrier;
|
use crate::core::sets::HealFormatAfterSaveBarrier;
|
||||||
use crate::disk::error::Result as DiskResult;
|
use crate::disk::error::Result as DiskResult;
|
||||||
@@ -1149,7 +1149,7 @@ mod tests {
|
|||||||
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
let (temp_dir, store, shutdown) = multi_pool_heal_store().await;
|
||||||
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
let target = remove_heal_test_format(&temp_dir, &store, 0, 3).await;
|
||||||
let capacity_guard = store
|
let capacity_guard = store
|
||||||
.acquire_external_decommission_capacity_fence(&[0], "heal")
|
.acquire_external_decommission_capacity_fence(&[0], DecommissionCapacityAdmission::Heal)
|
||||||
.await
|
.await
|
||||||
.expect("ordinary heal capacity fence should be acquired");
|
.expect("ordinary heal capacity fence should be acquired");
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -164,6 +164,16 @@ pub fn max_keys_plus_one(max_keys: i32, add_one: bool) -> i32 {
|
|||||||
max_keys
|
max_keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_versions_scan_limit(max_keys: i32, has_version_marker: bool) -> i32 {
|
||||||
|
if max_keys <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The marker object's versions may all be filtered out after gathering.
|
||||||
|
// Reserve its raw entry in addition to the next-page lookahead entry.
|
||||||
|
max_keys_plus_one(max_keys, true) + i32::from(has_version_marker)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
enum GatherResultsState {
|
enum GatherResultsState {
|
||||||
LimitReached,
|
LimitReached,
|
||||||
@@ -2139,15 +2149,19 @@ fn build_list_versions_next_marker(
|
|||||||
// here; advertise it as the literal `null` marker so a resumed listing
|
// here; advertise it as the literal `null` marker so a resumed listing
|
||||||
// parses it back to `VersionMarker::Null` instead of a nil UUID that
|
// parses it back to `VersionMarker::Null` instead of a nil UUID that
|
||||||
// `find_version_index` can never match (issue #6745).
|
// `find_version_index` can never match (issue #6745).
|
||||||
(
|
let version_marker = if last.is_dir && last.mod_time.is_none() {
|
||||||
Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)),
|
// A CommonPrefix has no version to resume; a version marker would
|
||||||
|
// make the next page include this same prefix again.
|
||||||
|
None
|
||||||
|
} else {
|
||||||
Some(
|
Some(
|
||||||
last.version_id
|
last.version_id
|
||||||
.filter(|v| !v.is_nil())
|
.filter(|v| !v.is_nil())
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_else(|| "null".to_string()),
|
.unwrap_or_else(|| "null".to_string()),
|
||||||
),
|
)
|
||||||
)
|
};
|
||||||
|
(Some(append_list_cache_id_to_marker(last.name.clone(), cache_id)), version_marker)
|
||||||
} else if let Some(last_prefix) = prefixes.last() {
|
} else if let Some(last_prefix) = prefixes.last() {
|
||||||
(Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None)
|
(Some(append_list_cache_id_to_marker(last_prefix.clone(), cache_id)), None)
|
||||||
} else {
|
} else {
|
||||||
@@ -2866,6 +2880,20 @@ fn listing_entries_supplement_target(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(directory) = entries.0.iter().flatten().find(|entry| entry.is_dir()) {
|
||||||
|
let directory_copies = entries
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.is_dir() && entry.name == directory.name)
|
||||||
|
.count();
|
||||||
|
// A committed child may have some of its directory copies only on
|
||||||
|
// fallback disks, just like object metadata in a partial primary sample.
|
||||||
|
if directory_copies < resolver.dir_quorum {
|
||||||
|
return Some(directory.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (idx, entry) in entries.0.iter().enumerate() {
|
for (idx, entry) in entries.0.iter().enumerate() {
|
||||||
let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else {
|
let Some(entry) = entry.as_ref().filter(|entry| entry.is_object()) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -4018,8 +4046,7 @@ impl ECStore {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||||
// Always request max_keys + 1 to detect if there are more results
|
|
||||||
let mut opts = ListPathOptions {
|
let mut opts = ListPathOptions {
|
||||||
bucket: bucket.to_owned(),
|
bucket: bucket.to_owned(),
|
||||||
prefix: prefix.to_owned(),
|
prefix: prefix.to_owned(),
|
||||||
@@ -5325,7 +5352,7 @@ impl Sets {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||||
let mut opts = ListPathOptions {
|
let mut opts = ListPathOptions {
|
||||||
bucket: bucket.to_owned(),
|
bucket: bucket.to_owned(),
|
||||||
prefix: prefix.to_owned(),
|
prefix: prefix.to_owned(),
|
||||||
@@ -6034,7 +6061,7 @@ impl SetDisks {
|
|||||||
|
|
||||||
let has_version_marker = version_marker.is_some();
|
let has_version_marker = version_marker.is_some();
|
||||||
let version_marker = version_marker.map(parse_version_marker).transpose()?;
|
let version_marker = version_marker.map(parse_version_marker).transpose()?;
|
||||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||||
let mut opts = ListPathOptions {
|
let mut opts = ListPathOptions {
|
||||||
bucket: bucket.to_owned(),
|
bucket: bucket.to_owned(),
|
||||||
prefix: prefix.to_owned(),
|
prefix: prefix.to_owned(),
|
||||||
@@ -6248,7 +6275,7 @@ impl SetDisks {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let effective_max_keys = if max_keys <= 0 { 0 } else { max_keys_plus_one(max_keys, true) };
|
let effective_max_keys = list_versions_scan_limit(max_keys, has_version_marker);
|
||||||
let mut opts = ListPathOptions {
|
let mut opts = ListPathOptions {
|
||||||
bucket: bucket.to_owned(),
|
bucket: bucket.to_owned(),
|
||||||
prefix: prefix.to_owned(),
|
prefix: prefix.to_owned(),
|
||||||
@@ -7441,6 +7468,153 @@ mod test {
|
|||||||
assert!(cancel.is_cancelled());
|
assert!(cancel.is_cancelled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_versions_pagination_scan_limit_boundaries() {
|
||||||
|
for has_version_marker in [false, true] {
|
||||||
|
assert_eq!(super::list_versions_scan_limit(-1, has_version_marker), 0);
|
||||||
|
assert_eq!(super::list_versions_scan_limit(0, has_version_marker), 0);
|
||||||
|
let marker_slot = i32::from(has_version_marker);
|
||||||
|
assert_eq!(super::list_versions_scan_limit(1, has_version_marker), 2 + marker_slot);
|
||||||
|
assert_eq!(super::list_versions_scan_limit(MAX_OBJECT_LIST, has_version_marker), 1001 + marker_slot);
|
||||||
|
assert_eq!(super::list_versions_scan_limit(i32::MAX, has_version_marker), 1001 + marker_slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_versions_pagination_does_not_require_an_empty_final_page() {
|
||||||
|
use crate::bucket::metadata_sys::{init_bucket_metadata_sys, test_support::isolated_store_over_temp_disks};
|
||||||
|
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||||
|
|
||||||
|
let (dirs, store) = isolated_store_over_temp_disks().await;
|
||||||
|
let bucket = "version-pagination-bucket";
|
||||||
|
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
store
|
||||||
|
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("pagination bucket should be created");
|
||||||
|
let mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||||
|
|
||||||
|
for kind in ["objects", "deletes", "null", "mixed", "delimiter"] {
|
||||||
|
let count = if kind == "mixed" { 5 } else { 10 };
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for index in 0..count {
|
||||||
|
let name = if kind == "delimiter" && index % 2 == 1 {
|
||||||
|
format!("{kind}/testobject-{index:02}/child")
|
||||||
|
} else {
|
||||||
|
format!("{kind}/testobject-{index:02}")
|
||||||
|
};
|
||||||
|
let entry = match kind {
|
||||||
|
"deletes" => test_delete_marker_meta_entry(&name, mod_time),
|
||||||
|
"null" => test_object_meta_entry(&name),
|
||||||
|
"mixed" => test_object_with_delete_marker_meta_entry(&name, mod_time, mod_time + time::Duration::SECOND),
|
||||||
|
_ => test_object_meta_entry_with_erasure_versions(&name, &[(mod_time, "etag", 2, 2)]),
|
||||||
|
};
|
||||||
|
for dir in &dirs {
|
||||||
|
let object_dir = dir.path().join(bucket).join(&name);
|
||||||
|
tokio::fs::create_dir_all(&object_dir)
|
||||||
|
.await
|
||||||
|
.expect("pagination object directory should be created");
|
||||||
|
tokio::fs::write(object_dir.join(STORAGE_FORMAT_FILE), &entry.metadata)
|
||||||
|
.await
|
||||||
|
.expect("pagination metadata should be written");
|
||||||
|
}
|
||||||
|
if kind == "delimiter" && index % 2 == 1 {
|
||||||
|
expected.push((name.trim_end_matches("child").to_owned(), None, false));
|
||||||
|
} else {
|
||||||
|
let versions = entry.file_info_versions(bucket).expect("fixture versions should decode");
|
||||||
|
expected.extend(
|
||||||
|
versions
|
||||||
|
.versions
|
||||||
|
.iter()
|
||||||
|
.map(|version| (name.clone(), version.version_id, version.deleted)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prefix = format!("{kind}/");
|
||||||
|
let delimiter = (kind == "delimiter").then(|| "/".to_owned());
|
||||||
|
// Exercise each public/internal entry point with the reported page size.
|
||||||
|
// The store entry point also covers exact and one-over limit boundaries.
|
||||||
|
for (layer, max_keys) in [(0, 0), (0, 1), (0, 5), (0, 9), (0, 10), (0, 11), (1, 5), (2, 5), (3, 5)] {
|
||||||
|
if layer == 3 && delimiter.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut marker = None;
|
||||||
|
let mut version_marker = None;
|
||||||
|
let expected_pages = if max_keys == 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
10usize.div_ceil(usize::try_from(max_keys).expect("positive page size"))
|
||||||
|
};
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
for page in 0..expected_pages {
|
||||||
|
let result = match layer {
|
||||||
|
0 => {
|
||||||
|
store
|
||||||
|
.clone()
|
||||||
|
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
store.pools[0]
|
||||||
|
.clone()
|
||||||
|
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
store.pools[0].disk_set[0]
|
||||||
|
.clone()
|
||||||
|
.inner_list_object_versions(bucket, &prefix, marker, version_marker, delimiter.clone(), max_keys)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
store.pools[0].disk_set[0]
|
||||||
|
.clone()
|
||||||
|
.inner_list_object_versions_for_recursive_delete(
|
||||||
|
bucket,
|
||||||
|
&prefix,
|
||||||
|
marker,
|
||||||
|
version_marker,
|
||||||
|
max_keys,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.expect("version page should list successfully");
|
||||||
|
let page_size = usize::try_from(max_keys).expect("nonnegative page size");
|
||||||
|
assert_eq!(result.objects.len() + result.prefixes.len(), (10 - page * page_size).min(page_size));
|
||||||
|
let has_more = page + 1 < expected_pages;
|
||||||
|
assert_eq!(result.is_truncated, has_more, "{kind}, layer {layer}, max_keys {max_keys}, page {page}");
|
||||||
|
assert_eq!(
|
||||||
|
result.next_marker.is_some(),
|
||||||
|
has_more,
|
||||||
|
"key marker must exist only when another page exists"
|
||||||
|
);
|
||||||
|
if !has_more {
|
||||||
|
assert!(
|
||||||
|
result.next_version_idmarker.is_none(),
|
||||||
|
"the final page must not advertise a version marker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
actual.extend(
|
||||||
|
result
|
||||||
|
.objects
|
||||||
|
.into_iter()
|
||||||
|
.map(|object| (object.name, object.version_id, object.delete_marker)),
|
||||||
|
);
|
||||||
|
actual.extend(result.prefixes.into_iter().map(|prefix| (prefix, None, false)));
|
||||||
|
marker = result.next_marker;
|
||||||
|
version_marker = result.next_version_idmarker;
|
||||||
|
}
|
||||||
|
// Objects and CommonPrefixes are serialized separately; compare their
|
||||||
|
// identities without relying on their relative position in the response.
|
||||||
|
actual.sort();
|
||||||
|
let mut expected = if max_keys == 0 { Vec::new() } else { expected.clone() };
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(actual, expected, "{kind}, layer {layer}, max_keys {max_keys}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_marker_is_applied_only_when_key_marker_entry_is_present() {
|
fn version_marker_is_applied_only_when_key_marker_entry_is_present() {
|
||||||
let version_marker = Some(VersionMarker::Null);
|
let version_marker = Some(VersionMarker::Null);
|
||||||
@@ -9448,6 +9622,71 @@ mod test {
|
|||||||
assert!(supplemented.is_latest_delete_marker());
|
assert!(supplemented.is_latest_delete_marker());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn latest_listing_supplement_checks_fallback_disks_for_common_prefix_quorum() {
|
||||||
|
let mut fallback_disks = Vec::new();
|
||||||
|
let mut fallback_tempdirs = Vec::new();
|
||||||
|
for index in 0..4 {
|
||||||
|
let tempdir = tempfile::tempdir().expect("fallback tempdir should be created");
|
||||||
|
let endpoint = Endpoint::try_from(tempdir.path().to_str().expect("fallback path should be utf8"))
|
||||||
|
.expect("fallback endpoint should parse");
|
||||||
|
let disk = new_disk(
|
||||||
|
&endpoint,
|
||||||
|
&DiskOption {
|
||||||
|
cleanup: false,
|
||||||
|
health_check: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("fallback disk should be created");
|
||||||
|
disk.make_volume("bucket").await.expect("fallback bucket should be created");
|
||||||
|
for copies in [3, 4] {
|
||||||
|
if index < copies {
|
||||||
|
let object = format!("quux-{copies}/thud");
|
||||||
|
let entry = test_object_meta_entry(&object);
|
||||||
|
disk.write_all("bucket", &format!("{object}/{STORAGE_FORMAT_FILE}"), bytes::Bytes::from(entry.metadata))
|
||||||
|
.await
|
||||||
|
.expect("fallback child metadata should be written");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallback_disks.push(disk);
|
||||||
|
fallback_tempdirs.push(tempdir);
|
||||||
|
}
|
||||||
|
let supplement = ListingSupplement::new(
|
||||||
|
ListingSupplementOptions {
|
||||||
|
bucket: "bucket".to_owned(),
|
||||||
|
path: String::new(),
|
||||||
|
recursive: false,
|
||||||
|
incl_deleted: false,
|
||||||
|
skip_hidden_prefix_check: false,
|
||||||
|
filter_prefix: None,
|
||||||
|
forward_to: None,
|
||||||
|
per_disk_limit: 100,
|
||||||
|
skip_total_timeout: true,
|
||||||
|
walkdir_timeout: None,
|
||||||
|
walkdir_stall_timeout: None,
|
||||||
|
},
|
||||||
|
Arc::new(fallback_disks),
|
||||||
|
FallbackClaimTracker::default(),
|
||||||
|
);
|
||||||
|
// A 16-drive EC:4 set asks 12 primary disks. A committed write may
|
||||||
|
// exist on eight primary disks and all four remaining fallback disks.
|
||||||
|
let resolver = list_metadata_resolution_params("bucket".to_owned(), 4, 12, false, 0);
|
||||||
|
for fallback_copies in [3, 4] {
|
||||||
|
let prefix = format!("quux-{fallback_copies}/");
|
||||||
|
let mut primary = vec![Some(test_dir_meta_entry(&prefix)); 8];
|
||||||
|
primary.extend([None, None, None, None]);
|
||||||
|
let entry =
|
||||||
|
resolve_listing_entries_with_supplement(MetaCacheEntries(primary), resolver.clone(), true, supplement.clone())
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
entry.map(|entry| entry.name),
|
||||||
|
(fallback_copies == 4).then_some(prefix),
|
||||||
|
"the common prefix needs all twelve copies, including fallback disks"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() {
|
fn latest_listing_supplement_keeps_a_subquorum_delete_marker_hidden() {
|
||||||
let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
let object_mod_time = time::OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user