mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 05:36:24 +00:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,8 @@ 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_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 +41,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
|
||||||
|
|||||||
@@ -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,325 @@
|
|||||||
"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."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ]
|
|
||||||
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."
|
|
||||||
+82
-78
@@ -16,7 +16,7 @@ name: Continuous Integration
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "**.md"
|
- "**.md"
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
@@ -36,26 +36,7 @@ on:
|
|||||||
- "flake.lock"
|
- "flake.lock"
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main ]
|
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/
|
||||||
@@ -872,13 +840,14 @@ jobs:
|
|||||||
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
|
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
|
||||||
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
||||||
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
|
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
|
||||||
# every PR, so it is gated to main pushes, the merge queue, and manual
|
# every PR, so it is gated to main/release pushes, the merge queue, and manual
|
||||||
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
|
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
|
||||||
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
github.event_name == 'merge_group' ||
|
github.event_name == 'merge_group' ||
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|
(github.event_name == 'push' &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release'))
|
||||||
needs: [ build-rustfs-debug-binary ]
|
needs: [ build-rustfs-debug-binary ]
|
||||||
runs-on: sm-standard-2
|
runs-on: sm-standard-2
|
||||||
timeout-minutes: 55
|
timeout-minutes: 55
|
||||||
@@ -1212,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
+2
@@ -9928,6 +9928,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"base64-simd",
|
"base64-simd",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc-fast",
|
"crc-fast",
|
||||||
"futures",
|
"futures",
|
||||||
"hotpath",
|
"hotpath",
|
||||||
@@ -10578,6 +10579,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",
|
||||||
|
|||||||
+23
-6
@@ -46,14 +46,31 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
|||||||
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
||||||
- **简单易用**:设计简洁,易于部署和管理。
|
- **简单易用**:设计简洁,易于部署和管理。
|
||||||
|
|
||||||
|
状态说明:✅ 可用 —— 已发布并有 CI 门禁覆盖;🧪 预览 —— 已发布但需显式开关,或兼容性承诺有边界。
|
||||||
|
|
||||||
| 功能 | 状态 | 功能 | 状态 |
|
| 功能 | 状态 | 功能 | 状态 |
|
||||||
| :----------------- | :------ | :---------------------- | :-------- |
|
| :-------------------------- | :------ | :----------------------- | :------ |
|
||||||
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
| **S3 核心功能** | ✅ 可用 | **分布式模式** | ✅ 可用 |
|
||||||
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
||||||
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
| **版本控制** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
||||||
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
|
| **对象锁定 (WORM)** | ✅ 可用 | **修复与扫描器** | ✅ 可用 |
|
||||||
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
|
| **服务端加密 (SSE)** | ✅ 可用 | **存储池扩容 / 下线** | ✅ 可用 |
|
||||||
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
|
| **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 性能对比
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
Ok(listener) => {
|
||||||
drop(listener);
|
drop(listener);
|
||||||
return Ok(port);
|
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,29 @@ 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<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct EvidenceTopology {
|
||||||
|
nodes: usize,
|
||||||
|
drives_per_node: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceTopology {
|
||||||
|
const fn new(nodes: usize, drives_per_node: usize) -> Self {
|
||||||
|
Self { nodes, drives_per_node }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_drives(self) -> usize {
|
||||||
|
self.nodes * self.drives_per_node
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster_topology(self) -> ClusterTopology {
|
||||||
|
ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
@@ -67,6 +90,9 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
@@ -74,6 +100,29 @@ 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
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"),
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RestartEvidenceContext {
|
struct RestartEvidenceContext {
|
||||||
@@ -332,11 +381,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 +1021,26 @@ 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")]
|
#[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 +1078,38 @@ mod tests {
|
|||||||
IsolatedTargetRestart,
|
IsolatedTargetRestart,
|
||||||
BackgroundTargetRestart,
|
BackgroundTargetRestart,
|
||||||
BackgroundTargetCrash,
|
BackgroundTargetCrash,
|
||||||
|
BackgroundTargetRestartEc84,
|
||||||
|
BackgroundTargetCrashEc84,
|
||||||
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)?
|
|
||||||
}
|
|
||||||
_ => 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::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));
|
||||||
init_logging();
|
init_logging();
|
||||||
info!(
|
info!(
|
||||||
event = "heal_interruption_started",
|
event = "heal_interruption_started",
|
||||||
@@ -1044,9 +1121,15 @@ 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);
|
||||||
|
}
|
||||||
// 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 +1178,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 +1205,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 +1218,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 +1266,38 @@ 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)?;
|
||||||
assert!(
|
assert!(
|
||||||
census.is_complete(),
|
census.is_complete(),
|
||||||
"online node {node_index} must hold a complete outage-object shard: {census:?}"
|
"online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}"
|
||||||
);
|
);
|
||||||
let erasure_index = census
|
let erasure_index = census.erasure_index.ok_or_else(|| {
|
||||||
.erasure_index
|
format!("online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}")
|
||||||
.ok_or_else(|| format!("online node {node_index} outage-object shard has no erasure index: {census:?}"))?;
|
})?;
|
||||||
assert!(
|
assert!(
|
||||||
(1..=cluster.nodes.len()).contains(&erasure_index),
|
(1..=topology.total_drives()).contains(&erasure_index),
|
||||||
"online node {node_index} outage-object erasure index is out of range: {census:?}"
|
"online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
outage_peer_erasure_indices.insert(erasure_index),
|
outage_peer_erasure_indices.insert(erasure_index),
|
||||||
"outage-object erasure index {erasure_index} is duplicated across online nodes"
|
"outage-object erasure index {erasure_index} is duplicated across online drives"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
outage_peer_erasure_indices.len(),
|
outage_peer_erasure_indices.len(),
|
||||||
cluster.nodes.len().saturating_sub(1),
|
topology.total_drives().saturating_sub(cluster.nodes[1].data_dirs.len()),
|
||||||
"every online node must contribute one unique outage-object erasure index"
|
"every online drive must contribute one unique outage-object erasure index"
|
||||||
|
);
|
||||||
|
let missing_outage_erasure_indices = (1..=topology.total_drives())
|
||||||
|
.filter(|index| !outage_peer_erasure_indices.contains(index))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
missing_outage_erasure_indices.len(),
|
||||||
|
cluster.nodes[1].data_dirs.len(),
|
||||||
|
"the stopped node must account for every missing 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 +1567,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 +1594,10 @@ 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
|
||||||
|
);
|
||||||
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"
|
||||||
@@ -1586,29 +1698,35 @@ mod tests {
|
|||||||
"outage object must have a complete target shard: {outage_census:?}"
|
"outage object must have a complete target shard: {outage_census:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
outage_census
|
||||||
|
.erasure_index
|
||||||
|
.filter(|index| missing_outage_erasure_indices.contains(index)),
|
||||||
outage_census.erasure_index,
|
outage_census.erasure_index,
|
||||||
Some(expected_outage_target_erasure_index),
|
"the outage object must be rebuilt into one of the stopped node's missing erasure slots"
|
||||||
"the outage object must be rebuilt into its own missing erasure slot"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 +1769,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();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,7 +563,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,
|
||||||
|
|||||||
@@ -4036,6 +4036,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 +4255,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 =
|
||||||
@@ -5161,6 +5165,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 +9957,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]
|
||||||
|
|||||||
@@ -8229,7 +8229,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,18 +8257,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 =
|
|
||||||
crate::object_api::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 {
|
||||||
@@ -8428,7 +8415,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),
|
||||||
});
|
});
|
||||||
@@ -8670,7 +8656,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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -544,6 +545,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,19 +1731,54 @@ 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)
|
||||||
|
.await;
|
||||||
|
if result
|
||||||
|
.as_ref()
|
||||||
|
.err()
|
||||||
|
.is_some_and(heal_control_auth_may_need_replay_scope_refresh)
|
||||||
|
{
|
||||||
|
self.prepare_heal_control_auth_retry().await;
|
||||||
|
return self
|
||||||
|
.finalize_result(
|
||||||
|
self.heal_control_once(version, &topology_fingerprint, &command, capability_probe)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
self.finalize_result(result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_heal_control_auth_retry(&self) {
|
||||||
|
if let Err(err) = clear_peer_replay_state_for_addr(&self.grid_host) {
|
||||||
|
debug!(
|
||||||
|
peer = %self.grid_host,
|
||||||
|
error = %err,
|
||||||
|
"could not clear heal control replay state before retry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.evict_connection().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn heal_control_once(
|
||||||
|
&self,
|
||||||
|
version: u32,
|
||||||
|
topology_fingerprint: &str,
|
||||||
|
command: &[u8],
|
||||||
|
capability_probe: bool,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
let mut client = self
|
let mut client = self
|
||||||
.get_heal_control_client()
|
.get_heal_control_client()
|
||||||
.await?
|
.await?
|
||||||
.max_encoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE)
|
.max_encoding_message_size(rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE)
|
||||||
.max_decoding_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)
|
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"))?;
|
.map_err(|_| Error::other("heal control request length cannot be represented"))?;
|
||||||
let mut request = Request::new(HealControlRequest {
|
let mut request = Request::new(HealControlRequest {
|
||||||
version,
|
version,
|
||||||
topology_fingerprint: topology_fingerprint.clone(),
|
topology_fingerprint: topology_fingerprint.to_string(),
|
||||||
command: command.clone().into(),
|
command: command.to_vec().into(),
|
||||||
});
|
});
|
||||||
request.set_timeout(rustfs_protos::heal_control_execution_timeout());
|
request.set_timeout(rustfs_protos::heal_control_execution_timeout());
|
||||||
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
|
set_tonic_canonical_body_digest(&mut request, &canonical_body)?;
|
||||||
@@ -1745,21 +1791,13 @@ impl PeerRestClient {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !capability_probe {
|
if !capability_probe {
|
||||||
let canonical_response = rustfs_protos::canonical_heal_control_response_body(
|
let canonical_response =
|
||||||
version,
|
rustfs_protos::canonical_heal_control_response_body(version, topology_fingerprint, command, &response.result)
|
||||||
&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
|
||||||
/// contract and has the same storage
|
/// contract and has the same storage
|
||||||
@@ -3728,6 +3766,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)?;
|
||||||
|
if let Ok(mut client) = self.get_client().await {
|
||||||
let _ = self
|
let _ = self
|
||||||
.execute_rpc("check_status_release", &resource_summary, client.un_lock(release_req))
|
.execute_rpc(
|
||||||
|
"check_status_release",
|
||||||
|
&resource_summary,
|
||||||
|
Self::rpc_timeout(),
|
||||||
|
async move { client.un_lock(release_req).await },
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await;
|
.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);
|
||||||
|
|
||||||
|
|||||||
+1040
-87
File diff suppressed because it is too large
Load Diff
@@ -570,6 +570,465 @@ mod decommission_lock_order_tests {
|
|||||||
.expect("decommission activation should commit after the probe release");
|
.expect("decommission activation should commit after the probe release");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn staged_external_put_rechecks_retiring_source_on_another_node() {
|
||||||
|
run_large_stack_current_thread_async_test("staged-retiring-source", || async {
|
||||||
|
let (_temp_dirs, store, other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||||
|
let bucket = test_bucket("staged-source");
|
||||||
|
let object = "selected-before-retirement.bin";
|
||||||
|
let original = b"original source object";
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create staged source bucket");
|
||||||
|
store.pools[0]
|
||||||
|
.put_object(&bucket, object, &mut PutObjReader::from_vec(original.to_vec()), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("seed the source selected before retirement");
|
||||||
|
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
set_decommission_capacity_info_overrides_for_test(
|
||||||
|
other_store.id,
|
||||||
|
vec![vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
let barrier = DecommissionCapacityLockOrderBarrier::install(store.id, store.id);
|
||||||
|
barrier.pause_external_object_commit_phase();
|
||||||
|
let put_store = Arc::clone(&store);
|
||||||
|
let put_bucket = bucket.clone();
|
||||||
|
let put = tokio::spawn(async move {
|
||||||
|
put_store
|
||||||
|
.put_object(
|
||||||
|
&put_bucket,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(b"must not replace a retiring source".to_vec()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_external_object_commit_phase_started())
|
||||||
|
.await
|
||||||
|
.expect("public PUT must stage before its decommission commit probe");
|
||||||
|
assert!(!store.pool_meta.read().await.is_suspended(0));
|
||||||
|
other_store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("the other node should activate retirement before the staged PUT commits");
|
||||||
|
assert!(other_store.pool_meta.read().await.is_suspended(0));
|
||||||
|
assert!(
|
||||||
|
!store.pool_meta.read().await.is_suspended(0),
|
||||||
|
"the writer's local snapshot must remain stale to exercise the durable admission probe"
|
||||||
|
);
|
||||||
|
barrier.release_external_object_commit_phase();
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(30), put)
|
||||||
|
.await
|
||||||
|
.expect("staged PUT must finish after the commit probe is released")
|
||||||
|
.expect("staged PUT must not panic");
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(crate::error::Error::SlowDown)),
|
||||||
|
"a staged PUT must retry pool selection instead of committing to a newly retiring source: {result:?}"
|
||||||
|
);
|
||||||
|
let mut reader = store.pools[0]
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the original source must remain readable after admission rejects the replacement");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("read the full retained source body");
|
||||||
|
assert_eq!(body, original);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn reserved_target_shares_business_io_and_retains_source_after_capacity_loss() {
|
||||||
|
run_large_stack_current_thread_async_test("shared-decommission-capacity", || async {
|
||||||
|
for lose_capacity in [false, true] {
|
||||||
|
let (_temp_dirs, store, other_store) =
|
||||||
|
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let bucket = test_bucket("shared-capacity");
|
||||||
|
let object = "migrating-source.bin";
|
||||||
|
let business_object = "business-write.bin";
|
||||||
|
let multipart_object = "business-multipart.bin";
|
||||||
|
let source_body = vec![0x35; 256 * 1024];
|
||||||
|
let business_body = vec![0x57; 64 * 1024];
|
||||||
|
store
|
||||||
|
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("create shared-capacity bucket");
|
||||||
|
store.pools[0]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(source_body.clone()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed the retiring source");
|
||||||
|
store.pools[2]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
business_object,
|
||||||
|
&mut PutObjReader::from_vec(b"previous business value".to_vec()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("pin the public overwrite to the migration target");
|
||||||
|
let multipart_opts = ObjectOptions {
|
||||||
|
expected_bucket_incarnation_id: Some(
|
||||||
|
store
|
||||||
|
.bucket_incarnation_id(&bucket)
|
||||||
|
.await
|
||||||
|
.expect("load the multipart bucket identity"),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let routing_upload = new_multipart_upload(&store, 2, &bucket, multipart_object, multipart_opts.clone())
|
||||||
|
.await
|
||||||
|
.expect("pin subsequent public multipart creation to the migration target");
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
let target_total = source_body.len() * 8;
|
||||||
|
let capacities = vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, source_body.len() * 2, source_body.len() * 2),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
||||||
|
];
|
||||||
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("activate source retirement");
|
||||||
|
*other_store.pool_meta.write().await = store.pool_meta.read().await.clone();
|
||||||
|
let before = other_store.pool_meta.read().await.clone();
|
||||||
|
let reservation = before.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("active source")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("durable reservation");
|
||||||
|
assert_eq!(
|
||||||
|
reservation.model_version, 2,
|
||||||
|
"exercise migration I/O outside the global metadata write lock"
|
||||||
|
);
|
||||||
|
assert_eq!(reservation.targets[0].pool_index, 2);
|
||||||
|
|
||||||
|
let barrier =
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let migration_store = Arc::clone(&store);
|
||||||
|
let migration_bucket = bucket.clone();
|
||||||
|
let migration = tokio::spawn(async move {
|
||||||
|
migration_store
|
||||||
|
.decommission_entry_for_test_with_bucket_incarnation(
|
||||||
|
0,
|
||||||
|
MetaCacheEntry {
|
||||||
|
name: object.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
migration_bucket,
|
||||||
|
migration_store.pools[0].get_disks_by_key(object),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("migration must reach target publication");
|
||||||
|
assert!(!migration.is_finished());
|
||||||
|
let mut pending = crate::core::pools::PoolMeta::default();
|
||||||
|
pending
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("read migration intent from the other node");
|
||||||
|
let pending_reservation = pending.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("active source")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("pending reservation")
|
||||||
|
.clone();
|
||||||
|
assert_eq!(pending_reservation.pending_target_physical_bytes, source_body.len());
|
||||||
|
assert_eq!(pending_reservation.consumed_target_physical_bytes, 0);
|
||||||
|
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
other_store.put_object(
|
||||||
|
&bucket,
|
||||||
|
business_object,
|
||||||
|
&mut PutObjReader::from_vec(business_body.clone()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("business PUT must finish without waiting for the migration target gate")
|
||||||
|
.expect("a reserved healthy pool must accept ordinary PUT");
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
let upload = other_store
|
||||||
|
.new_multipart_upload(&bucket, multipart_object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the reserved target must accept public multipart creation");
|
||||||
|
assert_ne!(upload.upload_id, routing_upload.upload_id);
|
||||||
|
let lifecycle_guard = other_store
|
||||||
|
.acquire_bucket_lifecycle_read_lock(&bucket)
|
||||||
|
.await
|
||||||
|
.expect("fence the exact-pool multipart placement check");
|
||||||
|
let mut lookup_opts = multipart_opts.clone();
|
||||||
|
lookup_opts.add_bucket_lifecycle_lock_guard(&lifecycle_guard);
|
||||||
|
other_store.pools[2]
|
||||||
|
.get_multipart_info(&bucket, multipart_object, &upload.upload_id, &lookup_opts)
|
||||||
|
.await
|
||||||
|
.expect("public multipart creation must actually select the reserved target");
|
||||||
|
drop(lifecycle_guard);
|
||||||
|
let mut final_part = None;
|
||||||
|
for payload in [vec![0x18; business_body.len()], business_body.clone()] {
|
||||||
|
final_part = Some(
|
||||||
|
other_store
|
||||||
|
.put_object_part(
|
||||||
|
&bucket,
|
||||||
|
multipart_object,
|
||||||
|
&upload.upload_id,
|
||||||
|
1,
|
||||||
|
&mut PutObjReader::from_vec(payload),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the reserved target must accept UploadPart and replacement of the same part"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let part = final_part.expect("the replacement part must be present");
|
||||||
|
Arc::clone(&other_store)
|
||||||
|
.complete_multipart_upload(
|
||||||
|
&bucket,
|
||||||
|
multipart_object,
|
||||||
|
&upload.upload_id,
|
||||||
|
vec![crate::storage_api_contracts::multipart::CompletePart {
|
||||||
|
part_num: part.part_num,
|
||||||
|
etag: part.etag,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the reserved target must accept multipart completion");
|
||||||
|
other_store
|
||||||
|
.abort_multipart_upload(&bucket, multipart_object, &routing_upload.upload_id, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("ordinary multipart cleanup must not consume the migration's pending intent");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("business multipart operations must finish while migration I/O is paused");
|
||||||
|
assert!(!migration.is_finished(), "business publication must overlap paused migration I/O");
|
||||||
|
let mut after_business = crate::core::pools::PoolMeta::default();
|
||||||
|
after_business
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload the shared-capacity ledger");
|
||||||
|
assert_eq!(
|
||||||
|
after_business.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("active source")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref(),
|
||||||
|
Some(&pending_reservation),
|
||||||
|
"ordinary PUT and multipart operations must not settle or consume the migration's pending identity"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut after_capacity = capacities;
|
||||||
|
// Capacity injection is deterministic; the object I/O and durable metadata use real temporary disks.
|
||||||
|
let free = if lose_capacity {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
target_total - source_body.len() - business_body.len() * 2
|
||||||
|
};
|
||||||
|
after_capacity[2] = DecommissionPoolCapacityInfo::for_test(2, layout, free, target_total, target_total - free);
|
||||||
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![after_capacity]);
|
||||||
|
barrier.release();
|
||||||
|
drop(barrier);
|
||||||
|
let migrated = tokio::time::timeout(Duration::from_secs(30), migration)
|
||||||
|
.await
|
||||||
|
.expect("migration must finish after publication resumes")
|
||||||
|
.expect("migration task must not panic");
|
||||||
|
if lose_capacity {
|
||||||
|
let err =
|
||||||
|
migrated.expect_err("capacity loss must prevent source cleanup, even after the target write commits");
|
||||||
|
assert!(err.to_string().contains("capacity"), "unexpected migration error: {err}");
|
||||||
|
} else {
|
||||||
|
migrated.expect("shared-capacity migration should finish when space remains sufficient");
|
||||||
|
}
|
||||||
|
let mut persisted = crate::core::pools::PoolMeta::default();
|
||||||
|
persisted
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload finalized migration state");
|
||||||
|
let info = persisted.pools[0].decommission.as_ref().expect("source state");
|
||||||
|
let reservation = info.capacity_reservation.as_ref().expect("migration ledger");
|
||||||
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
||||||
|
assert_eq!(
|
||||||
|
reservation.consumed_target_physical_bytes,
|
||||||
|
source_body.len(),
|
||||||
|
"foreign writes must not count as committed source bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(reservation.committed_data_bytes, source_body.len());
|
||||||
|
assert_eq!(info.capacity_blocked_reason.is_some(), lose_capacity);
|
||||||
|
for (pool, key, expected) in [
|
||||||
|
(2, business_object, &business_body),
|
||||||
|
(2, multipart_object, &business_body),
|
||||||
|
(2, object, &source_body),
|
||||||
|
] {
|
||||||
|
let mut reader = other_store.pools[pool]
|
||||||
|
.get_object_reader(&bucket, key, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("all acknowledged target objects must remain readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader.read_to_end(&mut actual).await.expect("read the complete target body");
|
||||||
|
assert_eq!(&actual, expected);
|
||||||
|
}
|
||||||
|
if lose_capacity {
|
||||||
|
let mut source = other_store.pools[0]
|
||||||
|
.get_object_reader(&bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("capacity-blocked migration must retain its source");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
source
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the complete retained source");
|
||||||
|
assert_eq!(actual, source_body);
|
||||||
|
other_store
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
business_object,
|
||||||
|
&mut PutObjReader::from_vec(business_body.clone()),
|
||||||
|
&ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("a capacity-blocked migration must not itself make the healthy target read-only");
|
||||||
|
} else {
|
||||||
|
let err = other_store.pools[0]
|
||||||
|
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("successful migration must clean the exact source");
|
||||||
|
assert!(crate::error::is_err_object_not_found(&err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn mixed_batch_delete_admits_only_marker_destinations_during_retirement() {
|
||||||
|
run_large_stack_current_thread_async_test("batch-marker-admission", || async {
|
||||||
|
use crate::storage_api_contracts::object::ObjectToDelete;
|
||||||
|
|
||||||
|
for marker_target in [1, 2] {
|
||||||
|
let (_temp_dirs, store, _other_store) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||||
|
let bucket = test_bucket("batch-marker");
|
||||||
|
store
|
||||||
|
.make_bucket(
|
||||||
|
&bucket,
|
||||||
|
&MakeBucketOptions {
|
||||||
|
versioning_enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create a versioned batch-delete bucket");
|
||||||
|
let source_version = uuid::Uuid::new_v4();
|
||||||
|
for (pool, object, version) in [
|
||||||
|
(0, "purge-source", source_version),
|
||||||
|
(marker_target, "mark-active", uuid::Uuid::new_v4()),
|
||||||
|
] {
|
||||||
|
store.pools[pool]
|
||||||
|
.put_object(
|
||||||
|
&bucket,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(b"version to delete".to_vec()),
|
||||||
|
&ObjectOptions {
|
||||||
|
versioned: true,
|
||||||
|
version_id: Some(version.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed each exact batch-delete destination");
|
||||||
|
}
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
set_decommission_capacity_info_overrides_for_test(
|
||||||
|
store.id,
|
||||||
|
vec![vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, 1024, 1024),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 4096, 4096, 0),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, 0, 4096, 4096),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("reserve pool 1 while pool 0 retires and pool 2 remains unreserved");
|
||||||
|
let (deleted, errors) = store
|
||||||
|
.delete_objects(
|
||||||
|
&bucket,
|
||||||
|
vec![
|
||||||
|
ObjectToDelete {
|
||||||
|
object_name: "mark-active".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ObjectToDelete {
|
||||||
|
object_name: "purge-source".to_string(),
|
||||||
|
version_id: Some(source_version),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ObjectOptions::default(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(errors.len(), 2);
|
||||||
|
assert!(
|
||||||
|
errors.iter().all(Option::is_none),
|
||||||
|
"the unrelated retiring/reserved pools must not reject marker admission: {errors:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(deleted.len(), 2);
|
||||||
|
assert_eq!(deleted[0].object_name, "mark-active");
|
||||||
|
assert!(deleted[0].delete_marker);
|
||||||
|
assert!(
|
||||||
|
deleted[0].version_id.is_none(),
|
||||||
|
"a latest-version delete does not request an explicit version"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
deleted[0].delete_marker_version_id.is_some(),
|
||||||
|
"the newly created marker must have its own version identity"
|
||||||
|
);
|
||||||
|
assert_eq!(deleted[1].object_name, "purge-source");
|
||||||
|
assert!(!deleted[1].delete_marker);
|
||||||
|
assert_eq!(deleted[1].version_id, Some(source_version));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
store.pools[0]
|
||||||
|
.get_object_info(
|
||||||
|
&bucket,
|
||||||
|
"purge-source",
|
||||||
|
&ObjectOptions {
|
||||||
|
version_id: Some(source_version.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Err(crate::error::Error::ObjectNotFound(..) | crate::error::Error::VersionNotFound(..))
|
||||||
|
),
|
||||||
|
"an exact source deletion must retain its capacity-release path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
async fn public_upload_part_holds_decommission_capacity_until_rename() {
|
async fn public_upload_part_holds_decommission_capacity_until_rename() {
|
||||||
@@ -4499,6 +4958,470 @@ mod decommission_lock_order_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_cas_keeps_fences_after_waiter_cancellation_until_rename_drains() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-canceled-waiter", async || {
|
||||||
|
temp_env::async_with_vars([(crate::set_disk::ENV_RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE, Some("true"))], async {
|
||||||
|
let (_temp_dirs, writer, other) =
|
||||||
|
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let set_index = 1;
|
||||||
|
let body = vec![0x37; 1024];
|
||||||
|
assert!(
|
||||||
|
!writer.pools[0].disk_set[0]
|
||||||
|
.shares_namespace_lock_domain(&writer.pools[0].disk_set[set_index])
|
||||||
|
.await
|
||||||
|
);
|
||||||
|
let rename_tasks = crate::set_disk::rename_fanout_barrier::observe_tasks(object);
|
||||||
|
let tail =
|
||||||
|
crate::set_disk::rename_fanout_barrier::arm(object, 0, crate::set_disk::rename_fanout_barrier::PHASE_RENAME);
|
||||||
|
let put_store = Arc::clone(&writer);
|
||||||
|
let put_body = body.clone();
|
||||||
|
let mut put = tokio::spawn(async move {
|
||||||
|
put_store
|
||||||
|
.save_scanner_pause_backlog_replica(0, set_index, put_body, Default::default())
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), tail.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("the native write must reach its held rename");
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
while rename_tasks.running() != 1 {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("the other disks must reach quorum before canceling the waiter");
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), &mut put).await.is_err(),
|
||||||
|
"native replica publication must await the entire rename tail"
|
||||||
|
);
|
||||||
|
put.abort();
|
||||||
|
assert!(put.await.expect_err("the scanner waiter must be canceled").is_cancelled());
|
||||||
|
|
||||||
|
let capacity_lock = other
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME)
|
||||||
|
.await
|
||||||
|
.expect("capacity lock probe");
|
||||||
|
let object_lock = other
|
||||||
|
.new_ns_lock(RUSTFS_META_BUCKET, object)
|
||||||
|
.await
|
||||||
|
.expect("fixed object lock probe");
|
||||||
|
let mut capacity_probe = tokio::spawn(async move { capacity_lock.get_write_lock(Duration::from_secs(30)).await });
|
||||||
|
let mut object_probe = tokio::spawn(async move { object_lock.get_write_lock(Duration::from_secs(30)).await });
|
||||||
|
for (label, probe) in [("capacity", &mut capacity_probe), ("fixed object", &mut object_probe)] {
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), probe).await.is_err(),
|
||||||
|
"canceling the scanner waiter must retain its {label} fence while rename is pending"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tail.release();
|
||||||
|
drop(tail);
|
||||||
|
for probe in [capacity_probe, object_probe] {
|
||||||
|
drop(
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), probe)
|
||||||
|
.await
|
||||||
|
.expect("publication fence must drain after rename")
|
||||||
|
.expect("lock probe must not panic")
|
||||||
|
.expect("publication fence must eventually be released"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut reader = writer.pools[0].disk_set[set_index]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("canceled waiter must leave the committed replica readable");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the full native replica after tail drain");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_cas_rejects_lost_capacity_lease_before_publication() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-lease-loss", async || {
|
||||||
|
let (_temp_dirs, writer, other) = test_three_pool_stores_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let body = b"native source before lease loss".to_vec();
|
||||||
|
let original = writer
|
||||||
|
.save_scanner_pause_backlog_replica(2, 1, body.clone(), Default::default())
|
||||||
|
.await
|
||||||
|
.expect("seed the exact native replica set");
|
||||||
|
let (lossy, refresh_calls) = store_with_capacity_lease_loss(&other).await;
|
||||||
|
let barrier = PutObjectCommitBarrier::install(RUSTFS_META_BUCKET, object, PutObjectCommitPause::BeforeQuotaRename);
|
||||||
|
let put = tokio::spawn(async move {
|
||||||
|
lossy
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
b"must not commit after lease loss".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: original.etag,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), barrier.wait_until_paused())
|
||||||
|
.await
|
||||||
|
.expect("native CAS must reach its commit barrier");
|
||||||
|
tokio::time::pause();
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
refresh_calls.arm();
|
||||||
|
tokio::time::advance(Duration::from_secs(11)).await;
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(
|
||||||
|
refresh_calls.load(Ordering::Acquire) > 0,
|
||||||
|
"the durable metadata lease must lose refresh quorum"
|
||||||
|
);
|
||||||
|
barrier.release();
|
||||||
|
tokio::time::resume();
|
||||||
|
let err = tokio::time::timeout(Duration::from_secs(30), put)
|
||||||
|
.await
|
||||||
|
.expect("native CAS must finish after the barrier release")
|
||||||
|
.expect("native CAS task must not panic")
|
||||||
|
.expect_err("a lost outer capacity lease must reject native publication");
|
||||||
|
assert!(
|
||||||
|
matches!(err, crate::error::Error::NamespaceLockQuorumUnavailable { .. }),
|
||||||
|
"unexpected lease error: {err}"
|
||||||
|
);
|
||||||
|
drop(barrier);
|
||||||
|
let mut reader = writer.pools[2].disk_set[1]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the preexisting replica must survive lease loss");
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
reader.read_to_end(&mut actual).await.expect("read the full retained replica");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_cas_rejects_a_retiring_source_on_a_stale_node() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-source-fence", async || {
|
||||||
|
let (_temp_dirs, store, writer) = test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let body = b"frozen native scanner replica".to_vec();
|
||||||
|
let source_set_index = (writer.pools[0].get_disks_by_key(object).set_index + 1) % writer.pools[0].disk_set.len();
|
||||||
|
assert_ne!(
|
||||||
|
source_set_index,
|
||||||
|
writer.pools[0].get_disks_by_key(object).set_index,
|
||||||
|
"exercise a non-routed native set"
|
||||||
|
);
|
||||||
|
let original = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
0,
|
||||||
|
source_set_index,
|
||||||
|
body.clone(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed a native scanner replica before retirement");
|
||||||
|
assert!(
|
||||||
|
writer
|
||||||
|
.scanner_pause_backlog_writable_set_disks()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|set| set.pool_index == 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
let target_total = body.len() * 8;
|
||||||
|
set_decommission_capacity_info_overrides_for_test(
|
||||||
|
store.id,
|
||||||
|
vec![vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("another node durably retires the selected source");
|
||||||
|
assert!(
|
||||||
|
writer.pool_meta.read().await.pools[0].decommission.is_none(),
|
||||||
|
"the writer must retain a stale snapshot"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
writer
|
||||||
|
.scanner_pause_backlog_writable_set_disks()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|set| set.pool_index == 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
0,
|
||||||
|
source_set_index,
|
||||||
|
b"late native scanner update".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: original.etag.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(crate::error::Error::SlowDown)),
|
||||||
|
"late native source publication must fail: {result:?}"
|
||||||
|
);
|
||||||
|
let mut source = writer.pools[0].disk_set[source_set_index]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the original source must remain readable");
|
||||||
|
assert_eq!(source.object_info.etag, original.etag);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
source
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the entire retained source");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
|
||||||
|
for set in &writer.pools[2].disk_set {
|
||||||
|
let target_body = format!("surviving native scanner set {}", set.set_index).into_bytes();
|
||||||
|
let committed = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
2,
|
||||||
|
set.set_index,
|
||||||
|
target_body.clone(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_none_match: Some("*".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("every reserved healthy target set must still accept scanner replicas");
|
||||||
|
let conflict = writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
2,
|
||||||
|
set.set_index,
|
||||||
|
b"must not bypass CAS".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: Some("stale-native-revision".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("capacity admission must retain the native writer's CAS");
|
||||||
|
assert!(matches!(conflict, crate::error::Error::PreconditionFailed));
|
||||||
|
let mut target = set
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("read the actual replica set, not the hash-routed set");
|
||||||
|
assert_eq!(target.object_info.etag, committed.etag);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
target
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read the complete native target");
|
||||||
|
assert_eq!(actual, target_body);
|
||||||
|
}
|
||||||
|
for (pool_index, set_index) in [(writer.pools.len(), 0), (0, writer.pools[0].disk_set.len())] {
|
||||||
|
assert!(matches!(
|
||||||
|
writer
|
||||||
|
.save_scanner_pause_backlog_replica(pool_index, set_index, Vec::new(), Default::default())
|
||||||
|
.await,
|
||||||
|
Err(crate::error::Error::InvalidArgument(_, _, _))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
store
|
||||||
|
.decommission_cancel(0)
|
||||||
|
.await
|
||||||
|
.expect("cancel retirement before restoring native membership");
|
||||||
|
writer
|
||||||
|
.save_scanner_pause_backlog_replica(
|
||||||
|
0,
|
||||||
|
source_set_index,
|
||||||
|
b"canceled source membership repair".to_vec(),
|
||||||
|
crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: original.etag,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("cancel must retain scanner's existing native membership repair contract");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial_test::serial]
|
||||||
|
fn scanner_backlog_native_replica_reconciles_capacity_and_cleans_source() {
|
||||||
|
run_large_stack_current_thread_async_test("scanner-backlog-reconcile", async || {
|
||||||
|
let (_temp_dirs, store, other_store) =
|
||||||
|
test_three_pool_stores_with_three_disk_sets_with_isolated_node_contexts(None).await;
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let body = br#"{"schemaVersion":1,"generation":2}"#.to_vec();
|
||||||
|
let old_body = br#"{"schemaVersion":1,"generation":1}"#.to_vec();
|
||||||
|
let source_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(20);
|
||||||
|
let target_time = time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(10);
|
||||||
|
for (pool_index, payload, mod_time) in [(0, body.clone(), source_time), (2, old_body, target_time)] {
|
||||||
|
store.pools[pool_index]
|
||||||
|
.put_object(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(payload),
|
||||||
|
&ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
mod_time: Some(mod_time),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("seed native scanner replicas with independent write times");
|
||||||
|
}
|
||||||
|
let layout = DecommissionErasureLayout { data: 1, parity: 0 };
|
||||||
|
let target_total = body.len() * 8;
|
||||||
|
let capacities = vec![
|
||||||
|
DecommissionPoolCapacityInfo::for_test(0, layout, 0, body.len() * 2, body.len() * 2),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(1, layout, 0, target_total, target_total),
|
||||||
|
DecommissionPoolCapacityInfo::for_test(2, layout, target_total, target_total, 0),
|
||||||
|
];
|
||||||
|
set_decommission_capacity_info_overrides_for_test(store.id, vec![capacities.clone()]);
|
||||||
|
store
|
||||||
|
.save_current_pool_meta_for_decommission_start(&[0], Vec::new())
|
||||||
|
.await
|
||||||
|
.expect("activate the source reservation");
|
||||||
|
let owner = decommission_capacity_owner(&*store.pool_meta.read().await);
|
||||||
|
let source_reader = store.pools[0]
|
||||||
|
.get_object_reader(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
object,
|
||||||
|
None,
|
||||||
|
HeaderMap::new(),
|
||||||
|
&ObjectOptions {
|
||||||
|
no_lock: true,
|
||||||
|
data_movement: true,
|
||||||
|
raw_data_movement_read: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("read the frozen source replica");
|
||||||
|
let conflict = data_movement::migrate_decommission_object(
|
||||||
|
Arc::clone(&store),
|
||||||
|
0,
|
||||||
|
RUSTFS_META_BUCKET.to_string(),
|
||||||
|
source_reader,
|
||||||
|
None,
|
||||||
|
"scanner_backlog_conflict",
|
||||||
|
Some(owner),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("a different older native ledger must retain its source and capacity intent");
|
||||||
|
assert!(conflict.to_string().contains("Precondition failed"), "unexpected conflict: {conflict}");
|
||||||
|
let mut persisted = crate::core::pools::PoolMeta::default();
|
||||||
|
persisted
|
||||||
|
.load_no_lock_from_replicas(store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload the unresolved intent");
|
||||||
|
assert_eq!(
|
||||||
|
persisted.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("source state")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("durable capacity")
|
||||||
|
.pending_target_physical_bytes,
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let previous = store.pools[2]
|
||||||
|
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("read the native writer's CAS revision");
|
||||||
|
let replacement = store.pools[2]
|
||||||
|
.put_object(
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
object,
|
||||||
|
&mut PutObjReader::from_vec(body.clone()),
|
||||||
|
&ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
mod_time: Some(target_time),
|
||||||
|
http_preconditions: Some(crate::storage_api_contracts::object::HTTPPreconditions {
|
||||||
|
if_match: previous.etag,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("native scanner CAS converges the payload without a migration marker");
|
||||||
|
assert!(!data_movement::is_owned_data_movement_target(&replacement));
|
||||||
|
*other_store.pool_meta.write().await = persisted;
|
||||||
|
set_decommission_capacity_info_overrides_for_test(other_store.id, vec![capacities]);
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
other_store.decommission_entry_for_test(
|
||||||
|
0,
|
||||||
|
MetaCacheEntry {
|
||||||
|
name: object.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
RUSTFS_META_BUCKET.to_string(),
|
||||||
|
other_store.pools[0].get_disks_by_key(object),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replica conflict recovery must be bounded")
|
||||||
|
.expect("identical native replica should finish migration on the reloaded node");
|
||||||
|
let mut reconciled = crate::core::pools::PoolMeta::default();
|
||||||
|
reconciled
|
||||||
|
.load_no_lock_from_replicas(other_store.pools.clone())
|
||||||
|
.await
|
||||||
|
.expect("reload reconciled capacity");
|
||||||
|
let reservation = reconciled.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("source state")
|
||||||
|
.capacity_reservation
|
||||||
|
.as_ref()
|
||||||
|
.expect("reconciled capacity");
|
||||||
|
assert_eq!(reservation.pending_target_physical_bytes, 0);
|
||||||
|
assert_eq!(reservation.committed_data_bytes, body.len());
|
||||||
|
assert_eq!(reservation.consumed_target_physical_bytes, body.len());
|
||||||
|
assert!(reservation.targets.iter().all(|target| target.pending_mutation_id.is_none()));
|
||||||
|
assert_eq!(
|
||||||
|
other_store.pool_meta.read().await.pools[0]
|
||||||
|
.decommission
|
||||||
|
.as_ref()
|
||||||
|
.expect("worker progress")
|
||||||
|
.items_decommission_failed,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let missing = other_store.pools[0]
|
||||||
|
.get_object_info(RUSTFS_META_BUCKET, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("the source should be cleaned only after equivalent-target capacity reconciliation");
|
||||||
|
assert!(crate::error::is_err_object_not_found(&missing));
|
||||||
|
let mut target_reader = other_store.pools[2]
|
||||||
|
.get_object_reader(RUSTFS_META_BUCKET, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the surviving replica should remain readable");
|
||||||
|
assert_eq!(
|
||||||
|
target_reader.object_info.mod_time,
|
||||||
|
Some(target_time),
|
||||||
|
"recovery must not overwrite the native target"
|
||||||
|
);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
target_reader
|
||||||
|
.read_to_end(&mut actual)
|
||||||
|
.await
|
||||||
|
.expect("read surviving ledger bytes");
|
||||||
|
assert_eq!(actual, body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial_test::serial]
|
#[serial_test::serial]
|
||||||
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
|
fn data_movement_equivalent_target_reconciles_published_capacity_after_restart() {
|
||||||
|
|||||||
@@ -984,6 +984,24 @@ fn is_superseding_unversioned_data_movement_object(source: &ObjectInfo, target:
|
|||||||
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
.is_some_and(|(source_time, target_time)| target_time > source_time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_equivalent_scanner_backlog_replica(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
||||||
|
// Scanner publishes this exact payload to surviving sets with CAS. Each
|
||||||
|
// set assigns its own write time; that timestamp is not a ledger generation.
|
||||||
|
// Accept only an identical, known unversioned identity, never a different
|
||||||
|
// record based on timestamp ordering or a similarly named user object.
|
||||||
|
source.bucket == crate::disk::RUSTFS_META_BUCKET
|
||||||
|
&& target.bucket == source.bucket
|
||||||
|
&& source.name == "buckets/.scanner-pause-backlog.json"
|
||||||
|
&& target.name == source.name
|
||||||
|
&& is_unversioned_data_movement_object(source)
|
||||||
|
&& is_unversioned_data_movement_object(target)
|
||||||
|
&& !source.delete_marker
|
||||||
|
&& source.mod_time.is_some()
|
||||||
|
&& target.mod_time.is_some()
|
||||||
|
&& source.etag.as_ref().is_some_and(|etag| !etag.is_empty())
|
||||||
|
&& is_equivalent_data_movement_object_identity(source, target, false, compare_part_checksums)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
fn is_data_movement_upload_takeover_target(source: &ObjectInfo, target: &ObjectInfo, compare_part_checksums: bool) -> bool {
|
||||||
let identity = data_movement_upload_identity(source);
|
let identity = data_movement_upload_identity(source);
|
||||||
source.mod_time.is_some()
|
source.mod_time.is_some()
|
||||||
@@ -1453,7 +1471,9 @@ fn resolve_data_movement_overwrite_resume_result_for(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(matches!(err, Error::PreconditionFailed) && is_superseding_unversioned_data_movement_object(source, &target))
|
Ok(matches!(err, Error::PreconditionFailed)
|
||||||
|
&& (is_equivalent_scanner_backlog_replica(source, &target, compare_part_checksums)
|
||||||
|
|| is_superseding_unversioned_data_movement_object(source, &target)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -1501,9 +1521,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 {
|
||||||
@@ -2408,8 +2446,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 +3333,132 @@ 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_accepts_identical_native_replica_with_older_write_time() {
|
||||||
|
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),
|
||||||
|
"identical ledger payloads have replica-local write times, not distinct committed generations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22463,6 +22463,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() {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::core::pools::{
|
use crate::core::pools::{
|
||||||
PoolMetaBootstrapAuthority, PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix,
|
PoolMetaReplicaState, PoolMetaWriteState, local_decommission_queue_prefix, persist_pool_meta_identity_for_attested_pools,
|
||||||
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
persist_pool_meta_identity_for_startup, pool_meta_has_active_decommission,
|
||||||
};
|
};
|
||||||
use crate::runtime::instance::InstanceContext;
|
use crate::runtime::instance::InstanceContext;
|
||||||
@@ -174,9 +174,20 @@ where
|
|||||||
S: EcstoreObjectIO,
|
S: EcstoreObjectIO,
|
||||||
{
|
{
|
||||||
if elected_writer && write_state.bootstrap_identity_proven() {
|
if elected_writer && write_state.bootstrap_identity_proven() {
|
||||||
persist_pool_meta_identity_for_startup(pools, write_state, false).await?;
|
return persist_pool_meta_identity_for_startup(pools, write_state, false).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
if write_state.bootstrap_identity_proven() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Multi-pool bootstrap whose pools were formatted by different nodes: no
|
||||||
|
// single process can prove the whole deployment fresh in memory, so each
|
||||||
|
// creator attests the pools it formatted first-hand with the shared nonce
|
||||||
|
// and the elected writer waits for a complete, agreeing pending set.
|
||||||
|
let attested = write_state.attested_pool_indices();
|
||||||
|
if attested.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
persist_pool_meta_identity_for_attested_pools(pools, write_state, &attested).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_validated_pool_meta_for_startup<S>(
|
async fn save_validated_pool_meta_for_startup<S>(
|
||||||
@@ -407,7 +418,7 @@ impl ECStore {
|
|||||||
preflight_startup_rpc_secret(&endpoint_pools)?;
|
preflight_startup_rpc_secret(&endpoint_pools)?;
|
||||||
|
|
||||||
let mut deployment_id = None;
|
let mut deployment_id = None;
|
||||||
let mut pool_meta_bootstrap_authority = None;
|
let mut pool_meta_bootstrap_authorities = Vec::new();
|
||||||
|
|
||||||
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
|
// let (endpoint_pools, _) = EndpointServerPools::create_server_endpoints(address.as_str(), &layouts)?;
|
||||||
|
|
||||||
@@ -523,12 +534,10 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}?;
|
}?;
|
||||||
pool_meta_bootstrap_authority = Some(pool_meta_bootstrap_authority.map_or(
|
// First-hand authority for this pool only: `Fresh` when this process
|
||||||
loaded_format.pool_meta_bootstrap_authority,
|
// formatted it, `LegacyAdoption` when it verified the migration, and
|
||||||
|authority: PoolMetaBootstrapAuthority| {
|
// `None` when it merely read a format another node created.
|
||||||
authority.combine_across_pools(loaded_format.pool_meta_bootstrap_authority)
|
pool_meta_bootstrap_authorities.push(loaded_format.pool_meta_bootstrap_authority);
|
||||||
},
|
|
||||||
));
|
|
||||||
let fm = loaded_format.format;
|
let fm = loaded_format.format;
|
||||||
|
|
||||||
// Format loading succeeded, enable health monitoring on all disks
|
// Format loading succeeded, enable health monitoring on all disks
|
||||||
@@ -569,9 +578,13 @@ impl ECStore {
|
|||||||
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
|
let peer_sys = S3PeerSys::new_with_instance_ctx(&endpoint_pools, instance_ctx.clone());
|
||||||
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
let mut pool_meta = PoolMeta::new(&pools, &PoolMeta::default());
|
||||||
pool_meta.dont_save = true;
|
pool_meta.dont_save = true;
|
||||||
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_bootstrap_authority(
|
let elected_bootstrap_writer = pools
|
||||||
|
.first()
|
||||||
|
.is_some_and(|pool| pool_first_endpoint_is_local(&pool.endpoints));
|
||||||
|
let pool_meta_write_state = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
deployment_id,
|
deployment_id,
|
||||||
pool_meta_bootstrap_authority.unwrap_or_default(),
|
pool_meta_bootstrap_authorities,
|
||||||
|
elected_bootstrap_writer,
|
||||||
);
|
);
|
||||||
|
|
||||||
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
|
let decommission_cancelers = RwLock::new(vec![None; pools.len()]);
|
||||||
@@ -961,8 +974,9 @@ mod tests {
|
|||||||
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
bucket::replication::{ReplicationState, ReplicationStatusType, replication_statuses_map},
|
||||||
core::pools::{
|
core::pools::{
|
||||||
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
|
DecommissionErasureLayout, DecommissionPoolCapacityInfo, POOL_META_IDENTITY_NAME, POOL_META_NAME, POOL_META_VERSION,
|
||||||
PoolDecommissionInfo, PoolMeta, PoolStatus, pool_meta_identity_initialized_for_test,
|
PoolDecommissionInfo, PoolMeta, PoolStatus, pending_pool_meta_identity_for_test,
|
||||||
pool_meta_v3_commit_state_for_test, set_decommission_capacity_info_overrides_for_test,
|
pool_meta_identity_initialized_for_test, pool_meta_v3_commit_state_for_test,
|
||||||
|
set_decommission_capacity_info_overrides_for_test,
|
||||||
},
|
},
|
||||||
disk::endpoint::Endpoint,
|
disk::endpoint::Endpoint,
|
||||||
error::{Error, Result, StorageError},
|
error::{Error, Result, StorageError},
|
||||||
@@ -1465,6 +1479,331 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn startup_object(storage: &StartupPoolMetaStorage, object: &str) -> Option<Vec<u8>> {
|
||||||
|
storage
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.get(object)
|
||||||
|
.map(|(payload, _)| payload.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startup errors wrap their cause in context whose `Display` hides the
|
||||||
|
/// source, so assertions walk the chain the same way
|
||||||
|
/// `Error::pool_metadata_failure` does.
|
||||||
|
fn error_chain_text(err: &Error) -> String {
|
||||||
|
let mut parts = vec![err.to_string()];
|
||||||
|
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
|
||||||
|
while let Some(error) = current {
|
||||||
|
current = if let Some(io) = error.downcast_ref::<std::io::Error>() {
|
||||||
|
io.get_ref().map(|inner| inner as &(dyn std::error::Error + 'static))
|
||||||
|
} else {
|
||||||
|
error.source()
|
||||||
|
};
|
||||||
|
if let Some(next) = current {
|
||||||
|
parts.push(next.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.join(" <- ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_startup_object(storage: &StartupPoolMetaStorage, object: &str, payload: Vec<u8>) {
|
||||||
|
storage
|
||||||
|
.objects
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.insert(object.to_string(), (payload, format!("injected-{object}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_test_pool_meta_with_pools(pool_count: usize) -> PoolMeta {
|
||||||
|
PoolMeta {
|
||||||
|
version: POOL_META_VERSION,
|
||||||
|
pools: (0..pool_count)
|
||||||
|
.map(|id| PoolStatus {
|
||||||
|
id,
|
||||||
|
cmd_line: format!("pool-{id}"),
|
||||||
|
last_update: OffsetDateTime::UNIX_EPOCH,
|
||||||
|
decommission: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
dont_save: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two single-node pools whose formats were created by different nodes:
|
||||||
|
/// node0 formatted pool0 and only read pool1's format, node1 the reverse.
|
||||||
|
fn two_pool_creator_states(deployment_id: Uuid) -> (PoolMetaWriteState, PoolMetaWriteState) {
|
||||||
|
let node0 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::Fresh, PoolMetaBootstrapAuthority::None],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let node1 = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
(node0, node1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_two_pool_bootstrap_with_distinct_format_creators_converges_through_creator_attestation() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
||||||
|
assert!(!node0.bootstrap_identity_proven(), "reading pool1's format is not deployment-wide proof");
|
||||||
|
assert!(!node1.bootstrap_identity_proven());
|
||||||
|
|
||||||
|
// node1 (pool1 creator, non-elected) starts first: no durable nonce exists
|
||||||
|
// yet, so it must neither mint one nor latch its write gate while waiting.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("a non-first creator without a durable nonce writes nothing");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_IDENTITY_NAME).is_none());
|
||||||
|
assert!(startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none());
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
||||||
|
.await
|
||||||
|
.expect_err("nothing durable authorizes a non-elected node");
|
||||||
|
assert!(err.to_string().contains("bootstrap pending"), "{err}");
|
||||||
|
node1
|
||||||
|
.ensure_write_safe("waiting non-elected creator")
|
||||||
|
.expect("waiting for the elected writer must not latch the write gate");
|
||||||
|
|
||||||
|
// node0 (pool0 creator, elected) mints the nonce on the pool it created;
|
||||||
|
// pool1 is still unattested, so it cannot publish pool.bin and must not latch.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("the first pool's creator mints the pending identity");
|
||||||
|
let minted = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("pool0 pending identity");
|
||||||
|
assert!(!pool_meta_identity_initialized_for_test(&minted).expect("decode pending identity"));
|
||||||
|
assert!(
|
||||||
|
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
||||||
|
"node0 holds no first-hand proof for pool1 and must not attest it"
|
||||||
|
);
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect_err("an unattested pool keeps the elected writer from publishing");
|
||||||
|
assert!(err.to_string().contains("waiting for every pool creator"), "{err}");
|
||||||
|
node0
|
||||||
|
.ensure_write_safe("waiting elected writer")
|
||||||
|
.expect("waiting for creators must not latch the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
|
||||||
|
// node1 retries: it copies pool0's pending identity (same nonce) onto the
|
||||||
|
// pool it created, then keeps waiting for the elected writer's pool.bin.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("the pool1 creator attests with the durable nonce");
|
||||||
|
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME).as_deref(), Some(minted.as_slice()));
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node1)
|
||||||
|
.await
|
||||||
|
.expect_err("a complete pending set never unlocks a non-elected node");
|
||||||
|
assert!(err.to_string().contains("waiting for the elected writer to publish"), "{err}");
|
||||||
|
node1
|
||||||
|
.ensure_write_safe("attested non-elected creator")
|
||||||
|
.expect("waiting for pool.bin must not latch the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
|
||||||
|
// node0 retries: every pool is attested under one nonce, so it publishes
|
||||||
|
// pool.bin and commits the identity on both pools.
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("re-establishing an already minted identity is idempotent");
|
||||||
|
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect("complete creator attestation authorizes the initial pool metadata write");
|
||||||
|
persist_pool_meta_for_startup_if_safe(
|
||||||
|
&init_test_pool_meta_with_pools(2),
|
||||||
|
pools.clone(),
|
||||||
|
replica_state,
|
||||||
|
&mut node0,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the elected writer publishes pool.bin and commits the identity");
|
||||||
|
for pool in [&pool0, &pool1] {
|
||||||
|
assert!(startup_object(pool, POOL_META_NAME).is_some());
|
||||||
|
let identity = startup_object(pool, POOL_META_IDENTITY_NAME).expect("committed identity");
|
||||||
|
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode committed identity"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// node1 retries once more: pool.bin exists and nothing is rewritten.
|
||||||
|
let before = startup_object(&pool1, POOL_META_IDENTITY_NAME);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("an initialized deployment never reopens bootstrap");
|
||||||
|
assert_eq!(startup_object(&pool1, POOL_META_IDENTITY_NAME), before);
|
||||||
|
load_pool_meta_for_startup(pools, &mut node1)
|
||||||
|
.await
|
||||||
|
.expect("published pool metadata admits the non-elected node");
|
||||||
|
node1
|
||||||
|
.ensure_write_safe("converged non-elected creator")
|
||||||
|
.expect("no latch remains after convergence");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_two_pool_bootstrap_rejects_pending_replicas_from_different_bootstraps() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("the first pool's creator mints the pending identity");
|
||||||
|
inject_startup_object(
|
||||||
|
&pool1,
|
||||||
|
POOL_META_IDENTITY_NAME,
|
||||||
|
pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode foreign pending identity"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect_err("a pending replica bound to another bootstrap nonce must fail closed");
|
||||||
|
let chain = error_chain_text(&err);
|
||||||
|
assert!(chain.contains("disagree on fresh-bootstrap proof"), "{chain}");
|
||||||
|
node0
|
||||||
|
.ensure_write_safe("split bootstrap")
|
||||||
|
.expect_err("a split bootstrap latches the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_two_pool_bootstrap_treats_corrupt_creator_replica_as_recovery_not_waiting() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, _) = two_pool_creator_states(deployment_id);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("the first pool's creator mints the pending identity");
|
||||||
|
// Keep the on-disk format/version header so the replica classifies as
|
||||||
|
// corrupt (undecodable payload) rather than as an incompatible format.
|
||||||
|
let mut corrupt = pending_pool_meta_identity_for_test(deployment_id, 1, Uuid::new_v4()).expect("encode identity");
|
||||||
|
corrupt.truncate(4);
|
||||||
|
corrupt.extend_from_slice(b"not a cluster identity");
|
||||||
|
inject_startup_object(&pool1, POOL_META_IDENTITY_NAME, corrupt);
|
||||||
|
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut node0)
|
||||||
|
.await
|
||||||
|
.expect_err("a corrupt replica is not a creator that is still catching up");
|
||||||
|
let chain = error_chain_text(&err);
|
||||||
|
assert!(chain.contains("no verified fresh-bootstrap proof"), "{chain}");
|
||||||
|
node0
|
||||||
|
.ensure_write_safe("corrupt attestation")
|
||||||
|
.expect_err("a corrupt attestation latches the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_elected_restart_without_first_hand_proof_cannot_reuse_a_complete_pending_set() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
let (mut node0, mut node1) = two_pool_creator_states(deployment_id);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node0, true)
|
||||||
|
.await
|
||||||
|
.expect("mint");
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut node1, false)
|
||||||
|
.await
|
||||||
|
.expect("attest");
|
||||||
|
assert_eq!(
|
||||||
|
startup_object(&pool0, POOL_META_IDENTITY_NAME),
|
||||||
|
startup_object(&pool1, POOL_META_IDENTITY_NAME),
|
||||||
|
"both creators attested the same pending identity"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The elected node restarts before publishing: it now merely reads both
|
||||||
|
// formats, so the complete pending set alone must not reopen bootstrap.
|
||||||
|
let mut restarted = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::None],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut restarted, true)
|
||||||
|
.await
|
||||||
|
.expect("a restart without first-hand proof writes nothing");
|
||||||
|
let err = load_pool_meta_for_startup(pools.clone(), &mut restarted)
|
||||||
|
.await
|
||||||
|
.expect_err("a pending set alone never authorizes a writer without first-hand proof");
|
||||||
|
assert!(err.to_string().contains("no verified fresh-bootstrap proof"), "{err}");
|
||||||
|
restarted
|
||||||
|
.ensure_write_safe("unproven restart")
|
||||||
|
.expect_err("the rejected restart latches the write gate");
|
||||||
|
assert!(startup_object(&pool0, POOL_META_NAME).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_fresh_pool_joining_an_initialized_deployment_never_reopens_bootstrap() {
|
||||||
|
let deployment_id = Uuid::new_v4();
|
||||||
|
let pool0 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let mut founder = PoolMetaWriteState::for_startup(deployment_id, true);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(vec![pool0.clone()], &mut founder, true)
|
||||||
|
.await
|
||||||
|
.expect("the founder mints");
|
||||||
|
let (_, replica_state) = load_pool_meta_for_startup(vec![pool0.clone()], &mut founder)
|
||||||
|
.await
|
||||||
|
.expect("the founder may initialize");
|
||||||
|
persist_pool_meta_for_startup_if_safe(
|
||||||
|
&init_test_pool_meta(None),
|
||||||
|
vec![pool0.clone()],
|
||||||
|
replica_state,
|
||||||
|
&mut founder,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the founder commits");
|
||||||
|
let founded = startup_object(&pool0, POOL_META_IDENTITY_NAME).expect("committed identity");
|
||||||
|
assert!(pool_meta_identity_initialized_for_test(&founded).expect("decode committed identity"));
|
||||||
|
|
||||||
|
// Expansion: pool1 is fresh and was formatted first-hand by the node
|
||||||
|
// hosting its first endpoint, whether or not that node is elected.
|
||||||
|
let pool1 = Arc::new(StartupPoolMetaStorage::new(Vec::new()));
|
||||||
|
let pools = vec![pool0.clone(), pool1.clone()];
|
||||||
|
for elected in [false, true] {
|
||||||
|
let mut joiner = PoolMetaWriteState::for_startup_with_pool_bootstrap_authorities(
|
||||||
|
deployment_id,
|
||||||
|
vec![PoolMetaBootstrapAuthority::None, PoolMetaBootstrapAuthority::Fresh],
|
||||||
|
elected,
|
||||||
|
);
|
||||||
|
establish_pool_meta_bootstrap_identity_if_proven(pools.clone(), &mut joiner, elected)
|
||||||
|
.await
|
||||||
|
.expect("an initialized deployment ignores first-hand proof for a new pool");
|
||||||
|
assert!(
|
||||||
|
startup_object(&pool1, POOL_META_IDENTITY_NAME).is_none(),
|
||||||
|
"no pending identity may be written to an expansion pool"
|
||||||
|
);
|
||||||
|
assert_eq!(startup_object(&pool0, POOL_META_IDENTITY_NAME).as_deref(), Some(founded.as_slice()));
|
||||||
|
let (_, replica_state) = load_pool_meta_for_startup(pools.clone(), &mut joiner)
|
||||||
|
.await
|
||||||
|
.expect("published pool metadata admits the joiner");
|
||||||
|
joiner
|
||||||
|
.ensure_write_safe("expansion joiner")
|
||||||
|
.expect("joining never latches the write gate");
|
||||||
|
if elected {
|
||||||
|
persist_pool_meta_for_startup_if_safe(
|
||||||
|
&init_test_pool_meta_with_pools(2),
|
||||||
|
pools.clone(),
|
||||||
|
replica_state,
|
||||||
|
&mut joiner,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the topology update repairs the new pool's replicas");
|
||||||
|
let identity = startup_object(&pool1, POOL_META_IDENTITY_NAME).expect("expansion pool identity");
|
||||||
|
assert!(pool_meta_identity_initialized_for_test(&identity).expect("decode repaired identity"));
|
||||||
|
assert!(startup_object(&pool1, POOL_META_NAME).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
|
async fn test_store_init_distinguishes_fresh_deployment_from_wiped_lagging_node() {
|
||||||
let deployment_id = Uuid::new_v4();
|
let deployment_id = Uuid::new_v4();
|
||||||
@@ -3859,13 +4198,60 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("suspended source versions should be readable")
|
.expect("suspended source versions should be readable")
|
||||||
.expect("suspended source must exist before worker convergence");
|
.expect("suspended source must exist before worker convergence");
|
||||||
|
assert_eq!(versions.versions.len(), 1, "DELETE must not add a marker to the retiring source");
|
||||||
|
let source = &versions.versions[0];
|
||||||
assert!(
|
assert!(
|
||||||
versions
|
!source.deleted && source.version_id.is_none_or(|version_id| version_id.is_nil()),
|
||||||
.versions
|
"the source pool must retain its null data version until worker convergence"
|
||||||
.iter()
|
|
||||||
.any(|version| !version.deleted && version.version_id.is_none_or(|version_id| version_id.is_nil())),
|
|
||||||
"the source pool must retain its null data version while DELETE owns the fixed fence"
|
|
||||||
);
|
);
|
||||||
|
assert_eq!(source.mod_time, Some(OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND));
|
||||||
|
|
||||||
|
let mut reader = store.pools[0]
|
||||||
|
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("the retiring source must remain directly readable before worker convergence");
|
||||||
|
let mut body = Vec::new();
|
||||||
|
reader
|
||||||
|
.stream
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.await
|
||||||
|
.expect("read retained source bytes");
|
||||||
|
assert_eq!(body, b"suspended source generation");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_suspended_null_delete_marker_visible(
|
||||||
|
store: &Arc<crate::store::ECStore>,
|
||||||
|
bucket: &str,
|
||||||
|
object: &str,
|
||||||
|
marker_mod_time: OffsetDateTime,
|
||||||
|
) {
|
||||||
|
let versions = store.pools[1]
|
||||||
|
.get_disks_by_key(object)
|
||||||
|
.load_file_info_versions_exact(bucket, object)
|
||||||
|
.await
|
||||||
|
.expect("healthy target versions should be readable")
|
||||||
|
.expect("the healthy target must retain the DELETE marker");
|
||||||
|
assert_eq!(versions.versions.len(), 1, "the target must contain only the null delete marker");
|
||||||
|
let marker = &versions.versions[0];
|
||||||
|
assert!(marker.deleted, "migration must not replace the DELETE marker with source data");
|
||||||
|
assert!(marker.version_id.is_none_or(|version_id| version_id.is_nil()));
|
||||||
|
assert_eq!(marker.size, 0);
|
||||||
|
assert_eq!(marker.mod_time, Some(marker_mod_time), "migration must preserve the marker generation");
|
||||||
|
assert!(marker_mod_time > OffsetDateTime::UNIX_EPOCH + time::Duration::SECOND);
|
||||||
|
|
||||||
|
let head_err = store
|
||||||
|
.get_object_info(bucket, object, &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
.expect_err("HEAD must observe the DELETE marker instead of the old null source");
|
||||||
|
assert!(matches!(head_err, Error::ObjectNotFound(_, _)), "unexpected HEAD result: {head_err:?}");
|
||||||
|
let get_err = match store
|
||||||
|
.get_object_reader(bucket, object, None, HeaderMap::new(), &ObjectOptions::default())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => panic!("GET must not resurrect the deleted null source"),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
assert!(matches!(get_err, Error::ObjectNotFound(_, _)), "unexpected GET result: {get_err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -7703,7 +8089,7 @@ mod tests {
|
|||||||
write_suspended_decommission_source(&store, &bucket, object).await;
|
write_suspended_decommission_source(&store, &bucket, object).await;
|
||||||
mark_test_pool_decommissioning(&store, 0).await;
|
mark_test_pool_decommissioning(&store, 0).await;
|
||||||
|
|
||||||
let delete_err = store
|
let deleted = store
|
||||||
.delete_object(
|
.delete_object(
|
||||||
&bucket,
|
&bucket,
|
||||||
object,
|
object,
|
||||||
@@ -7713,12 +8099,12 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("capacity-reserved target must reject a concurrent suspended DELETE");
|
.expect("a healthy reserved target must accept suspended DELETE");
|
||||||
assert!(
|
assert!(deleted.delete_marker);
|
||||||
matches!(delete_err, Error::SlowDown),
|
assert_eq!(deleted.version_id, Some(uuid::Uuid::nil()));
|
||||||
"unexpected suspended DELETE result: {delete_err:?}"
|
let marker_mod_time = deleted.mod_time.expect("DELETE must return the marker generation");
|
||||||
);
|
|
||||||
assert_suspended_null_source_present(&store, &bucket, object).await;
|
assert_suspended_null_source_present(&store, &bucket, object).await;
|
||||||
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
|
|
||||||
let source_set = store.pools[0].get_disks_by_key(object);
|
let source_set = store.pools[0].get_disks_by_key(object);
|
||||||
let worker_store = Arc::clone(&store);
|
let worker_store = Arc::clone(&store);
|
||||||
@@ -7738,7 +8124,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("suspended decommission worker should join")
|
.expect("suspended decommission worker should join")
|
||||||
.expect("worker must migrate the fenced suspended source");
|
.expect("worker must converge the old null source behind the newer DELETE marker");
|
||||||
|
|
||||||
assert_decommission_source_absent(
|
assert_decommission_source_absent(
|
||||||
&store,
|
&store,
|
||||||
@@ -7750,10 +8136,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
|
||||||
b"suspended source generation"
|
|
||||||
);
|
|
||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7786,7 +8169,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let (_deleted, errors) = store
|
let (deleted, errors) = store
|
||||||
.delete_objects(
|
.delete_objects(
|
||||||
&bucket,
|
&bucket,
|
||||||
vec![ObjectToDelete {
|
vec![ObjectToDelete {
|
||||||
@@ -7800,10 +8183,23 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
matches!(errors.as_slice(), [Some(Error::SlowDown)]),
|
matches!(errors.as_slice(), [None]),
|
||||||
"unexpected suspended batch DELETE result: {errors:?}"
|
"unexpected suspended batch DELETE result: {errors:?}"
|
||||||
);
|
);
|
||||||
|
assert_eq!(deleted.len(), 1);
|
||||||
|
assert!(deleted[0].delete_marker);
|
||||||
|
assert_eq!(deleted[0].object_name, object);
|
||||||
|
assert!(
|
||||||
|
deleted[0]
|
||||||
|
.delete_marker_version_id
|
||||||
|
.is_none_or(|version_id| version_id.is_nil()),
|
||||||
|
"batch DELETE must retain the native null version identity"
|
||||||
|
);
|
||||||
|
let marker_mod_time = deleted[0]
|
||||||
|
.delete_marker_mtime
|
||||||
|
.expect("batch DELETE must return the marker generation");
|
||||||
assert_suspended_null_source_present(&store, &bucket, object).await;
|
assert_suspended_null_source_present(&store, &bucket, object).await;
|
||||||
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
|
|
||||||
let source_set = store.pools[0].get_disks_by_key(object);
|
let source_set = store.pools[0].get_disks_by_key(object);
|
||||||
let worker_store = Arc::clone(&store);
|
let worker_store = Arc::clone(&store);
|
||||||
@@ -7823,7 +8219,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("suspended batch decommission worker should join")
|
.expect("suspended batch decommission worker should join")
|
||||||
.expect("worker must migrate the batch-fenced suspended source");
|
.expect("worker must converge the old null source behind the newer batch DELETE marker");
|
||||||
|
|
||||||
assert_decommission_source_absent(
|
assert_decommission_source_absent(
|
||||||
&store,
|
&store,
|
||||||
@@ -7835,10 +8231,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(
|
assert_suspended_null_delete_marker_visible(&store, &bucket, object, marker_mod_time).await;
|
||||||
read_decommission_target_body(&store, &bucket, object, &ObjectOptions::default()).await,
|
|
||||||
b"suspended source generation"
|
|
||||||
);
|
|
||||||
shutdown.cancel();
|
shutdown.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -762,13 +762,7 @@ impl ECStore {
|
|||||||
self.pools
|
self.pools
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(pool_index, _)| {
|
.filter(|(pool_index, _)| pool_meta.scanner_pause_backlog_pool_writable(*pool_index))
|
||||||
!pool_meta.pools.get(*pool_index).is_some_and(|pool| {
|
|
||||||
pool.decommission
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|info| info.has_decommission_state() && !info.failed && !info.canceled)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use crate::bucket::object_lock::objectlock_sys::{
|
|||||||
};
|
};
|
||||||
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
|
||||||
use crate::bucket::versioning::VersioningApi;
|
use crate::bucket::versioning::VersioningApi;
|
||||||
use crate::core::pools::{DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
use crate::core::pools::{DecommissionCapacityAdmission, DecommissionCapacityOwner, ensure_decommission_capacity_mutation_id};
|
||||||
use crate::disk::OldCurrentSize;
|
use crate::disk::OldCurrentSize;
|
||||||
use crate::object_api::{
|
use crate::object_api::{
|
||||||
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
|
NamespaceLockFence, ObjectLockConfigSnapshot, ScannerPublicationCommitScopeGuard, ScannerPublicationCommitState,
|
||||||
@@ -3060,6 +3060,68 @@ impl ECStore {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish a native scanner replica without allowing stale pool selection
|
||||||
|
/// to race retirement. Failed/canceled membership repair remains permitted.
|
||||||
|
/// A canceled waiter cannot release publication fences from an in-flight write.
|
||||||
|
pub async fn save_scanner_pause_backlog_replica(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
pool_index: usize,
|
||||||
|
set_index: usize,
|
||||||
|
data: Vec<u8>,
|
||||||
|
preconditions: crate::storage_api_contracts::object::HTTPPreconditions,
|
||||||
|
) -> Result<ObjectInfo> {
|
||||||
|
let set = self
|
||||||
|
.pools
|
||||||
|
.get(pool_index)
|
||||||
|
.and_then(|pool| pool.disk_set.get(set_index))
|
||||||
|
.ok_or_else(|| Error::InvalidArgument("scanner-backlog".into(), "replica".into(), "unknown pool or set".into()))?;
|
||||||
|
let set = Arc::clone(set);
|
||||||
|
let store = Arc::clone(self);
|
||||||
|
let write = async move {
|
||||||
|
let object = "buckets/.scanner-pause-backlog.json";
|
||||||
|
let mut opts = ObjectOptions {
|
||||||
|
max_parity: true,
|
||||||
|
http_preconditions: Some(preconditions),
|
||||||
|
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Match migration: fixed object namespace -> durable pool metadata ->
|
||||||
|
// actual replica namespace. The replica need not be the hash-routed set.
|
||||||
|
let object_guard = if store.single_pool() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
store
|
||||||
|
.acquire_object_write_lock("scanner_backlog_replica", RUSTFS_META_BUCKET, object)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let capacity_guard = if let Some(guard) = object_guard.as_ref() {
|
||||||
|
guard.add_namespace_lock_fence(&mut opts);
|
||||||
|
opts.no_lock = match store.pools.first().and_then(|pool| pool.disk_set.first()) {
|
||||||
|
Some(fixed) => fixed.shares_namespace_lock_domain(&set).await,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
let capacity_guard = store
|
||||||
|
.acquire_external_decommission_capacity_fence(&[pool_index], DecommissionCapacityAdmission::ScannerBacklog)
|
||||||
|
.await?;
|
||||||
|
opts.add_namespace_lock_guard(&capacity_guard);
|
||||||
|
Some(capacity_guard)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let result = set
|
||||||
|
.put_object(RUSTFS_META_BUCKET, object, &mut PutObjReader::from_vec(data), &opts)
|
||||||
|
.await;
|
||||||
|
drop(capacity_guard);
|
||||||
|
drop(object_guard);
|
||||||
|
result
|
||||||
|
};
|
||||||
|
// The set layer may detach its rename owner, even for full-tail writes.
|
||||||
|
// Keep these outer guards alive until that owner finishes if scanner exits.
|
||||||
|
tokio::spawn(write).await.map_err(Error::from)?
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn run_external_decommission_capacity_object_mutation<T, F, Fut>(
|
pub(super) async fn run_external_decommission_capacity_object_mutation<T, F, Fut>(
|
||||||
&self,
|
&self,
|
||||||
target_pool_idx: usize,
|
target_pool_idx: usize,
|
||||||
@@ -3129,7 +3191,10 @@ impl ECStore {
|
|||||||
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
let (capacity_guard, has_active_decommission) = if capacity_releasing {
|
||||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
self.acquire_decommission_capacity_release_fence_with_active_source().await?
|
||||||
} else {
|
} else {
|
||||||
self.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
self.acquire_external_decommission_capacity_fence_with_active_source(
|
||||||
|
&[target_pool_idx],
|
||||||
|
DecommissionCapacityAdmission::Mutation,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||||
@@ -3146,7 +3211,7 @@ impl ECStore {
|
|||||||
let capacity_guard = if capacity_releasing {
|
let capacity_guard = if capacity_releasing {
|
||||||
self.acquire_decommission_capacity_release_fence_with_active_source().await?.0
|
self.acquire_decommission_capacity_release_fence_with_active_source().await?.0
|
||||||
} else {
|
} else {
|
||||||
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], "mutation")
|
self.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Mutation)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
(capacity_guard, Some(guard))
|
(capacity_guard, Some(guard))
|
||||||
@@ -3176,9 +3241,10 @@ impl ECStore {
|
|||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
no_lock: bool,
|
no_lock: bool,
|
||||||
|
admission: DecommissionCapacityAdmission,
|
||||||
) -> Result<(Option<ObjectLockDiagGuard>, bool, Option<rustfs_lock::NamespaceLockGuard>)> {
|
) -> Result<(Option<ObjectLockDiagGuard>, bool, Option<rustfs_lock::NamespaceLockGuard>)> {
|
||||||
let (capacity_guard, has_active_decommission) = self
|
let (capacity_guard, has_active_decommission) = self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "mutation")
|
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], admission)
|
||||||
.await?;
|
.await?;
|
||||||
if !has_active_decommission {
|
if !has_active_decommission {
|
||||||
// Keep the read probe through the staged commit. This closes the
|
// Keep the read probe through the staged commit. This closes the
|
||||||
@@ -3227,7 +3293,10 @@ impl ECStore {
|
|||||||
return operation(opts).await;
|
return operation(opts).await;
|
||||||
}
|
}
|
||||||
let (capacity_guard, has_active_decommission) = self
|
let (capacity_guard, has_active_decommission) = self
|
||||||
.acquire_external_decommission_capacity_fence_with_active_source(&[target_pool_idx], "heal")
|
.acquire_external_decommission_capacity_fence_with_active_source(
|
||||||
|
&[target_pool_idx],
|
||||||
|
DecommissionCapacityAdmission::Heal,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
let (capacity_guard, object_guard) = if has_active_decommission && !opts.no_lock {
|
||||||
// Active migration acquires the object namespace before its capacity
|
// Active migration acquires the object namespace before its capacity
|
||||||
@@ -3248,7 +3317,7 @@ impl ECStore {
|
|||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
let capacity_guard = self
|
let capacity_guard = self
|
||||||
.acquire_external_decommission_capacity_fence(&[target_pool_idx], "heal")
|
.acquire_external_decommission_capacity_fence(&[target_pool_idx], DecommissionCapacityAdmission::Heal)
|
||||||
.await?;
|
.await?;
|
||||||
opts.no_lock = target_lock_covered;
|
opts.no_lock = target_lock_covered;
|
||||||
(capacity_guard, Some(guard))
|
(capacity_guard, Some(guard))
|
||||||
@@ -5093,9 +5162,14 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
let _capacity_fence = if !self.single_pool() && latest_marker_objects.iter().any(|creates_marker| *creates_marker) {
|
||||||
let target_pool_indices = (0..self.pools.len()).collect::<Vec<_>>();
|
// Only marker destinations can grow. Other pools participate in
|
||||||
|
// exact deletion under the same metadata read fence and must not
|
||||||
|
// be treated as publication targets merely because they retire.
|
||||||
|
let mut target_pool_indices = marker_target_pool_indices.iter().flatten().copied().collect::<Vec<_>>();
|
||||||
|
target_pool_indices.sort_unstable();
|
||||||
|
target_pool_indices.dedup();
|
||||||
match self
|
match self
|
||||||
.acquire_external_decommission_capacity_fence(&target_pool_indices, "batch_delete")
|
.acquire_external_decommission_capacity_fence(&target_pool_indices, DecommissionCapacityAdmission::BatchDelete)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(fence) => Some(fence),
|
Ok(fence) => Some(fence),
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ walkdir = { workspace = true }
|
|||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
temp-env = { workspace = true, features = ["async_closure"] }
|
temp-env = { workspace = true, features = ["async_closure"] }
|
||||||
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["test-util", "fs", "rt-multi-thread"] }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ use std::time::{Duration, UNIX_EPOCH};
|
|||||||
use tokio::sync::{RwLock, Semaphore};
|
use tokio::sync::{RwLock, Semaphore};
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
use super::{DiskStore, EcstoreError};
|
use super::{DiskStore, EcstoreError, POOL_META_NAME, RUSTFS_META_BUCKET};
|
||||||
|
|
||||||
/// Outcome of classifying an error returned by [`HealStorageAPI::heal_object`].
|
/// Outcome of classifying an error returned by [`HealStorageAPI::heal_object`].
|
||||||
enum HealObjectOutcome {
|
enum HealObjectOutcome {
|
||||||
@@ -68,6 +68,13 @@ struct PageConcurrencyGuard {
|
|||||||
set_label: String,
|
set_label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ErasureSetPassCounters<'a> {
|
||||||
|
processed_objects: &'a mut u64,
|
||||||
|
successful_objects: &'a mut u64,
|
||||||
|
failed_objects: &'a mut u64,
|
||||||
|
skipped_objects: &'a mut u64,
|
||||||
|
}
|
||||||
|
|
||||||
impl PageConcurrencyGuard {
|
impl PageConcurrencyGuard {
|
||||||
fn new(in_flight: Arc<AtomicUsize>, set_label: String) -> Self {
|
fn new(in_flight: Arc<AtomicUsize>, set_label: String) -> Self {
|
||||||
let current = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
let current = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
@@ -106,6 +113,7 @@ pub struct ErasureSetHealer {
|
|||||||
heal_opts: HealOpts,
|
heal_opts: HealOpts,
|
||||||
source: HealRequestSource,
|
source: HealRequestSource,
|
||||||
target_endpoints: Arc<[String]>,
|
target_endpoints: Arc<[String]>,
|
||||||
|
pool_metadata_target_endpoints: Arc<[String]>,
|
||||||
replacement_task_id: Option<String>,
|
replacement_task_id: Option<String>,
|
||||||
replacement_target_identities: Option<Arc<[ReplacementTargetIdentity]>>,
|
replacement_target_identities: Option<Arc<[ReplacementTargetIdentity]>>,
|
||||||
mainline_pacer: Option<Arc<super::pacing::MainlinePacer>>,
|
mainline_pacer: Option<Arc<super::pacing::MainlinePacer>>,
|
||||||
@@ -355,6 +363,7 @@ impl ErasureSetHealer {
|
|||||||
heal_opts,
|
heal_opts,
|
||||||
source,
|
source,
|
||||||
target_endpoints: Vec::new().into(),
|
target_endpoints: Vec::new().into(),
|
||||||
|
pool_metadata_target_endpoints: Vec::new().into(),
|
||||||
replacement_task_id: None,
|
replacement_task_id: None,
|
||||||
replacement_target_identities: None,
|
replacement_target_identities: None,
|
||||||
mainline_pacer: None,
|
mainline_pacer: None,
|
||||||
@@ -378,6 +387,13 @@ impl ErasureSetHealer {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_pool_metadata_targets(mut self, mut target_endpoints: Vec<String>) -> Self {
|
||||||
|
target_endpoints.sort_unstable();
|
||||||
|
target_endpoints.dedup();
|
||||||
|
self.pool_metadata_target_endpoints = target_endpoints.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn with_replacement_identity_fence(
|
pub(crate) fn with_replacement_identity_fence(
|
||||||
mut self,
|
mut self,
|
||||||
replacement_target_identities: Option<Vec<ReplacementTargetIdentity>>,
|
replacement_target_identities: Option<Vec<ReplacementTargetIdentity>>,
|
||||||
@@ -834,6 +850,21 @@ impl ErasureSetHealer {
|
|||||||
current_object_index = 0;
|
current_object_index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if failed_objects == 0 && skipped_objects == 0 && failed_buckets == 0 {
|
||||||
|
self.heal_replacement_pool_metadata(
|
||||||
|
set_disk_id,
|
||||||
|
&mut ErasureSetPassCounters {
|
||||||
|
processed_objects: &mut processed_objects,
|
||||||
|
successful_objects: &mut successful_objects,
|
||||||
|
failed_objects: &mut failed_objects,
|
||||||
|
skipped_objects: &mut skipped_objects,
|
||||||
|
},
|
||||||
|
resume_manager,
|
||||||
|
checkpoint_manager,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
// 5. finalize. Only declare the set healed when nothing failed AND
|
// 5. finalize. Only declare the set healed when nothing failed AND
|
||||||
// nothing was transiently skipped — otherwise the resume/checkpoint
|
// nothing was transiently skipped — otherwise the resume/checkpoint
|
||||||
// state must survive so the failed/skipped versions are retried instead
|
// state must survive so the failed/skipped versions are retried instead
|
||||||
@@ -919,6 +950,207 @@ impl ErasureSetHealer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn heal_replacement_pool_metadata(
|
||||||
|
&self,
|
||||||
|
set_disk_id: &str,
|
||||||
|
counters: &mut ErasureSetPassCounters<'_>,
|
||||||
|
resume_manager: &ResumeManager,
|
||||||
|
checkpoint_manager: &CheckpointManager,
|
||||||
|
) -> Result<()> {
|
||||||
|
let target_endpoints = if self.pool_metadata_target_endpoints.is_empty() {
|
||||||
|
self.target_endpoints.as_ref()
|
||||||
|
} else {
|
||||||
|
self.pool_metadata_target_endpoints.as_ref()
|
||||||
|
};
|
||||||
|
let target_scoped_recreate = !self.heal_opts.dry_run && self.heal_opts.recreate && !target_endpoints.is_empty();
|
||||||
|
if self.replacement_task_id.is_none() && !target_scoped_recreate {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if target_endpoints.is_empty() {
|
||||||
|
return Err(Error::TaskExecutionFailed {
|
||||||
|
message: "Replacement pool metadata heal requires target endpoints".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let object_key = format!("{RUSTFS_META_BUCKET}/{POOL_META_NAME}");
|
||||||
|
let checkpoint_key = compose_key(&object_key, None);
|
||||||
|
let checkpoint = checkpoint_manager.get_checkpoint().await;
|
||||||
|
if checkpoint.processed_objects.contains(&checkpoint_key)
|
||||||
|
|| checkpoint.failed_objects.contains(&checkpoint_key)
|
||||||
|
|| checkpoint.skipped_objects.contains(&checkpoint_key)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
drop(checkpoint);
|
||||||
|
|
||||||
|
self.verify_replacement_identity_fence("pool metadata").await?;
|
||||||
|
resume_manager
|
||||||
|
.set_current_item(Some(RUSTFS_META_BUCKET.to_string()), Some(POOL_META_NAME.to_string()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let result = match self
|
||||||
|
.storage
|
||||||
|
.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((result, None)) if target_outcomes_complete(&result, target_endpoints) => {
|
||||||
|
let object_size = result_object_size_u64(&result);
|
||||||
|
match self
|
||||||
|
.storage
|
||||||
|
.replacement_targets_have_version(RUSTFS_META_BUCKET, POOL_META_NAME, None, &self.heal_opts, target_endpoints)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => (object_size, Ok(())),
|
||||||
|
Ok(false) => (
|
||||||
|
object_size,
|
||||||
|
Err(Error::transient_skip(
|
||||||
|
"Skipped replacement pool metadata heal because target readback did not confirm the committed version",
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Err(err) => (
|
||||||
|
object_size,
|
||||||
|
Err(Error::transient_skip(format!(
|
||||||
|
"Skipped replacement pool metadata heal because target readback failed: {err}"
|
||||||
|
))),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((result, None)) => (
|
||||||
|
result_object_size_u64(&result),
|
||||||
|
Err(Error::transient_skip(
|
||||||
|
"Skipped replacement pool metadata heal because a replacement target was not committed",
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
Ok((result, Some(err))) => {
|
||||||
|
let object_size = result_object_size_u64(&result);
|
||||||
|
match Self::classify_heal_object_error(&err) {
|
||||||
|
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
|
||||||
|
object_size,
|
||||||
|
Err(Error::transient_skip(format!(
|
||||||
|
"Skipped replacement pool metadata heal due to transient error: {err}"
|
||||||
|
))),
|
||||||
|
),
|
||||||
|
HealObjectOutcome::Failed => (object_size, Err(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err @ Error::TaskCancelled) | Err(err @ Error::TaskTimeout) => return Err(err),
|
||||||
|
Err(err) => match Self::classify_heal_object_error(&err) {
|
||||||
|
HealObjectOutcome::Absent | HealObjectOutcome::Transient => (
|
||||||
|
0,
|
||||||
|
Err(Error::transient_skip(format!(
|
||||||
|
"Skipped replacement pool metadata heal due to transient error: {err}"
|
||||||
|
))),
|
||||||
|
),
|
||||||
|
HealObjectOutcome::Failed => (0, Err(err)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let (object_size, result) = result;
|
||||||
|
let mut bytes_processed = self.progress.read().await.bytes_processed;
|
||||||
|
let mut telemetry_unknown = false;
|
||||||
|
let checkpoint_outcome = match result {
|
||||||
|
Ok(()) => {
|
||||||
|
telemetry_unknown |= !increment_counter(counters.successful_objects);
|
||||||
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
|
debug!(
|
||||||
|
target: "rustfs::heal::erasure_healer",
|
||||||
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
|
||||||
|
set_disk_id,
|
||||||
|
bucket = RUSTFS_META_BUCKET,
|
||||||
|
object = POOL_META_NAME,
|
||||||
|
state = "healed",
|
||||||
|
"Replacement pool metadata healed"
|
||||||
|
);
|
||||||
|
CheckpointObjectOutcome::Processed
|
||||||
|
}
|
||||||
|
Err(Error::TransientSkip { message }) => {
|
||||||
|
telemetry_unknown |= !increment_counter(counters.skipped_objects);
|
||||||
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::heal::erasure_healer",
|
||||||
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
|
||||||
|
set_disk_id,
|
||||||
|
bucket = RUSTFS_META_BUCKET,
|
||||||
|
object = POOL_META_NAME,
|
||||||
|
state = "transient_skip",
|
||||||
|
error = %message,
|
||||||
|
"Replacement pool metadata heal skipped due to transient error"
|
||||||
|
);
|
||||||
|
CheckpointObjectOutcome::Skipped
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
telemetry_unknown |= !increment_counter(counters.failed_objects);
|
||||||
|
telemetry_unknown |= !add_bytes(&mut bytes_processed, object_size);
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::heal::erasure_healer",
|
||||||
|
event = EVENT_HEAL_ERASURE_OBJECT_STATE,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_ERASURE_HEALER,
|
||||||
|
set_disk_id,
|
||||||
|
bucket = RUSTFS_META_BUCKET,
|
||||||
|
object = POOL_META_NAME,
|
||||||
|
state = "failed",
|
||||||
|
error = %err,
|
||||||
|
"Replacement pool metadata heal failed"
|
||||||
|
);
|
||||||
|
CheckpointObjectOutcome::Failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
telemetry_unknown |= !increment_counter(counters.processed_objects);
|
||||||
|
let (outcome_record, counter_unknown, skipped_new_versions, skipped_ilm_expired) = {
|
||||||
|
let mut progress = self.progress.write().await;
|
||||||
|
progress.set_current_object(Some(object_key.clone()));
|
||||||
|
progress.update_object_progress(
|
||||||
|
*counters.processed_objects,
|
||||||
|
*counters.successful_objects,
|
||||||
|
*counters.failed_objects,
|
||||||
|
*counters.skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
);
|
||||||
|
if telemetry_unknown {
|
||||||
|
progress.mark_unknown();
|
||||||
|
}
|
||||||
|
(
|
||||||
|
CheckpointObjectOutcomeRecord {
|
||||||
|
object: checkpoint_key,
|
||||||
|
outcome: checkpoint_outcome,
|
||||||
|
successful: progress.objects_healed,
|
||||||
|
failed: progress.objects_failed,
|
||||||
|
skipped: progress.skipped_objects,
|
||||||
|
bytes: progress.bytes_processed,
|
||||||
|
skipped_new_versions: progress.skipped_new_versions,
|
||||||
|
skipped_ilm_expired: progress.skipped_ilm_expired,
|
||||||
|
counter_unknown: progress.counter_unknown,
|
||||||
|
},
|
||||||
|
progress.counter_unknown,
|
||||||
|
progress.skipped_new_versions,
|
||||||
|
progress.skipped_ilm_expired,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
checkpoint_manager.record_object_outcome(outcome_record).await?;
|
||||||
|
resume_manager
|
||||||
|
.update_progress_with_bytes(
|
||||||
|
*counters.processed_objects,
|
||||||
|
*counters.successful_objects,
|
||||||
|
*counters.failed_objects,
|
||||||
|
*counters.skipped_objects,
|
||||||
|
bytes_processed,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
resume_manager
|
||||||
|
.set_skipped_version_counts(skipped_new_versions, skipped_ilm_expired)
|
||||||
|
.await?;
|
||||||
|
if counter_unknown {
|
||||||
|
resume_manager.mark_counter_unknown().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// heal single bucket with resume
|
/// heal single bucket with resume
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[tracing::instrument(skip(self, current_object_index, processed_objects, successful_objects, failed_objects, skipped_objects, resume_manager, checkpoint_manager), fields(bucket = %bucket, bucket_index = bucket_index))]
|
#[tracing::instrument(skip(self, current_object_index, processed_objects, successful_objects, failed_objects, skipped_objects, resume_manager, checkpoint_manager), fields(bucket = %bucket, bucket_index = bucket_index))]
|
||||||
@@ -1679,7 +1911,8 @@ mod resume_loop_tests {
|
|||||||
use crate::heal::storage::{HealLifecycleExpiryContext, HealListItem, HealObjectInfo, HealStorageAPI};
|
use crate::heal::storage::{HealLifecycleExpiryContext, HealListItem, HealObjectInfo, HealStorageAPI};
|
||||||
use crate::heal::storage_api::status::BucketInfo;
|
use crate::heal::storage_api::status::BucketInfo;
|
||||||
use crate::heal::{
|
use crate::heal::{
|
||||||
BUCKET_META_PREFIX, DiskOption, DiskStore, EcstoreError, Endpoint, HealDiskExt as _, RUSTFS_META_BUCKET, new_disk,
|
BUCKET_META_PREFIX, DiskOption, DiskStore, EcstoreError, Endpoint, HealDiskExt as _, POOL_META_NAME, RUSTFS_META_BUCKET,
|
||||||
|
new_disk,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource};
|
use rustfs_heal_contracts::heal_channel::{HealOpts, HealRequestSource};
|
||||||
@@ -1773,6 +2006,28 @@ mod resume_loop_tests {
|
|||||||
assert!(!target_outcomes_complete(&duplicate, &["replacement-a".to_string()]));
|
assert!(!target_outcomes_complete(&duplicate, &["replacement-a".to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replacement_target_ok_result(endpoint: &str, object: &str) -> HealResultItem {
|
||||||
|
HealResultItem {
|
||||||
|
object: object.to_string(),
|
||||||
|
object_size: 1024,
|
||||||
|
before: Infos {
|
||||||
|
drives: vec![HealDriveInfo {
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
state: "missing".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
after: Infos {
|
||||||
|
drives: vec![HealDriveInfo {
|
||||||
|
endpoint: endpoint.to_string(),
|
||||||
|
state: "ok".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Page {
|
struct Page {
|
||||||
items: Vec<HealListItem>,
|
items: Vec<HealListItem>,
|
||||||
@@ -2444,6 +2699,8 @@ mod resume_loop_tests {
|
|||||||
HealRequestSource::AutoHeal,
|
HealRequestSource::AutoHeal,
|
||||||
)
|
)
|
||||||
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
|
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
|
||||||
|
env.storage
|
||||||
|
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
|
||||||
|
|
||||||
healer
|
healer
|
||||||
.heal_erasure_set(&["b".to_string()], "pool_0_set_0")
|
.heal_erasure_set(&["b".to_string()], "pool_0_set_0")
|
||||||
@@ -2461,9 +2718,121 @@ mod resume_loop_tests {
|
|||||||
CheckpointManager::has_checkpoint(&env.healer.disk, &replacement_task_id).await,
|
CheckpointManager::has_checkpoint(&env.healer.disk, &replacement_task_id).await,
|
||||||
"the checkpoint must survive until the caller clears the healing marker"
|
"the checkpoint must survive until the caller clears the healing marker"
|
||||||
);
|
);
|
||||||
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
drop(checkpoint);
|
drop(checkpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replacement_pool_metadata_readback_failure_schedules_retry() {
|
||||||
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
|
let replacement_task_id = ResumeUtils::generate_task_id();
|
||||||
|
ResumeManager::new_replacement_intent(
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
replacement_task_id.clone(),
|
||||||
|
"pool_0_set_0".to_string(),
|
||||||
|
vec!["b".to_string()],
|
||||||
|
vec!["replacement-a".to_string()],
|
||||||
|
vec![crate::heal::resume::ReplacementTargetIdentity {
|
||||||
|
endpoint: "replacement-a".to_string(),
|
||||||
|
canonical_path: "/mnt/replacement-a".to_string(),
|
||||||
|
physical_device_ids: vec!["device-a".to_string()],
|
||||||
|
filesystem_identity: "1:2:3".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replacement intent should persist");
|
||||||
|
env.storage
|
||||||
|
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
|
||||||
|
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
|
||||||
|
let healer = ErasureSetHealer::new(
|
||||||
|
env.storage.clone(),
|
||||||
|
Arc::new(RwLock::new(HealProgress::new())),
|
||||||
|
CancellationToken::new(),
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
HealOpts::default(),
|
||||||
|
HealRequestSource::AutoHeal,
|
||||||
|
)
|
||||||
|
.with_replacement_targets(vec!["replacement-a".to_string()], Some(replacement_task_id.clone()));
|
||||||
|
|
||||||
|
let error = healer
|
||||||
|
.heal_erasure_set(&["b".to_string()], "pool_0_set_0")
|
||||||
|
.await
|
||||||
|
.expect_err("unconfirmed pool metadata readback must keep the replacement incomplete");
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("Replacement erasure set heal incomplete"));
|
||||||
|
let state = ResumeManager::load_replacement_intent(env.healer.disk.clone(), &replacement_task_id)
|
||||||
|
.await
|
||||||
|
.expect("replacement retry state must remain")
|
||||||
|
.get_state()
|
||||||
|
.await;
|
||||||
|
assert!(!state.completed);
|
||||||
|
assert_eq!(state.replacement_phase, crate::heal::resume::ReplacementPhase::Intent);
|
||||||
|
assert_eq!(state.retry_count, 1);
|
||||||
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_recreate_target_heals_pool_metadata_before_completion() {
|
||||||
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
|
let healer = ErasureSetHealer::new(
|
||||||
|
env.storage.clone(),
|
||||||
|
Arc::new(RwLock::new(HealProgress::new())),
|
||||||
|
CancellationToken::new(),
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
HealOpts {
|
||||||
|
recreate: true,
|
||||||
|
pool: Some(0),
|
||||||
|
set: Some(0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealRequestSource::Admin,
|
||||||
|
)
|
||||||
|
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
|
||||||
|
env.storage
|
||||||
|
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
|
||||||
|
|
||||||
|
healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect("admin recreate should heal and verify pool metadata");
|
||||||
|
|
||||||
|
assert!(env.resume.get_state().await.completed);
|
||||||
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_recreate_pool_metadata_readback_failure_keeps_resume_state() {
|
||||||
|
let env = make_env_with_targets(vec!["replacement-a".to_string()]).await;
|
||||||
|
let healer = ErasureSetHealer::new(
|
||||||
|
env.storage.clone(),
|
||||||
|
Arc::new(RwLock::new(HealProgress::new())),
|
||||||
|
CancellationToken::new(),
|
||||||
|
env.healer.disk.clone(),
|
||||||
|
HealOpts {
|
||||||
|
recreate: true,
|
||||||
|
pool: Some(0),
|
||||||
|
set: Some(0),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealRequestSource::Admin,
|
||||||
|
)
|
||||||
|
.with_pool_metadata_targets(vec!["replacement-a".to_string()]);
|
||||||
|
env.storage
|
||||||
|
.set_result(POOL_META_NAME, None, replacement_target_ok_result("replacement-a", POOL_META_NAME));
|
||||||
|
env.storage.set_replacement_commit_evidence(POOL_META_NAME, None, false);
|
||||||
|
|
||||||
|
let error = healer
|
||||||
|
.execute_heal_with_resume(&["b".to_string()], "pool_0_set_0", &env.resume, &env.checkpoint)
|
||||||
|
.await
|
||||||
|
.expect_err("unconfirmed admin recreate pool metadata must keep the set incomplete");
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("Erasure set heal incomplete"));
|
||||||
|
let state = env.resume.get_state().await;
|
||||||
|
assert!(!state.completed);
|
||||||
|
assert_eq!(state.retry_count, 1);
|
||||||
|
assert_eq!(env.storage.calls(), vec![(POOL_META_NAME.to_string(), None)]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn retry_exhaustion_keeps_resume_artifacts_for_recovery() {
|
async fn retry_exhaustion_keeps_resume_artifacts_for_recovery() {
|
||||||
let env = make_env().await;
|
let env = make_env().await;
|
||||||
|
|||||||
+230
-20
@@ -506,6 +506,25 @@ fn active_heal_for_dedup_key(active_heals: &HashMap<String, Arc<HealTask>>, key:
|
|||||||
.map(|(task_id, task)| (task_id.clone(), task.heal_type.clone()))
|
.map(|(task_id, task)| (task_id.clone(), task.heal_type.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_matches_task(request: &HealRequest, task: &HealTask) -> bool {
|
||||||
|
request.heal_type == task.heal_type
|
||||||
|
&& request.options == task.options
|
||||||
|
&& request.priority == task.priority
|
||||||
|
&& request.source == task.source
|
||||||
|
&& request.retry_attempts == task.retry_attempts
|
||||||
|
&& request.heal_endpoints == task.heal_endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_matches_request(request: &HealRequest, existing: &HealRequest) -> bool {
|
||||||
|
request.heal_type == existing.heal_type
|
||||||
|
&& request.options == existing.options
|
||||||
|
&& request.priority == existing.priority
|
||||||
|
&& request.source == existing.source
|
||||||
|
&& request.force_start == existing.force_start
|
||||||
|
&& request.retry_attempts == existing.retry_attempts
|
||||||
|
&& request.heal_endpoints == existing.heal_endpoints
|
||||||
|
}
|
||||||
|
|
||||||
fn retrying_heal_for_dedup_key(retrying_heals: &HashMap<String, RetryingHeal>, key: &str) -> Option<(String, HealType)> {
|
fn retrying_heal_for_dedup_key(retrying_heals: &HashMap<String, RetryingHeal>, key: &str) -> Option<(String, HealType)> {
|
||||||
retrying_heals
|
retrying_heals
|
||||||
.iter()
|
.iter()
|
||||||
@@ -822,6 +841,10 @@ pub struct HealManager {
|
|||||||
replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>,
|
replacement_recovery_anchors: Arc<std::sync::Mutex<HashMap<String, String>>>,
|
||||||
/// Set IDs whose durable replacement metadata is corrupt or conflicting.
|
/// Set IDs whose durable replacement metadata is corrupt or conflicting.
|
||||||
replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>,
|
replacement_recovery_blocked_sets: Arc<std::sync::Mutex<HashSet<String>>>,
|
||||||
|
/// Durable handoff of interrupted administrator root traversals.
|
||||||
|
root_recovery: Arc<root_recovery::RootHealRecovery>,
|
||||||
|
/// Keep forceStart's cancellation side effects inside the shutdown fence.
|
||||||
|
force_start_shutdown: Mutex<()>,
|
||||||
/// Storage layer interface
|
/// Storage layer interface
|
||||||
storage: Arc<dyn HealStorageAPI>,
|
storage: Arc<dyn HealStorageAPI>,
|
||||||
/// Cancel token
|
/// Cancel token
|
||||||
@@ -857,6 +880,7 @@ struct HealQueueContext<'a> {
|
|||||||
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
|
retrying_heals: &'a Arc<Mutex<HashMap<String, RetryingHeal>>>,
|
||||||
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
|
mrf_repair_notice_targets: &'a Arc<StdMutex<HashMap<String, Vec<MrfRepairNoticeTarget>>>>,
|
||||||
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
|
replacement_recovery_anchors: &'a Arc<std::sync::Mutex<HashMap<String, String>>>,
|
||||||
|
root_recovery: &'a Arc<root_recovery::RootHealRecovery>,
|
||||||
config: &'a Arc<RwLock<HealConfig>>,
|
config: &'a Arc<RwLock<HealConfig>>,
|
||||||
statistics: &'a Arc<RwLock<HealStatistics>>,
|
statistics: &'a Arc<RwLock<HealStatistics>>,
|
||||||
storage: &'a Arc<dyn HealStorageAPI>,
|
storage: &'a Arc<dyn HealStorageAPI>,
|
||||||
@@ -1358,6 +1382,8 @@ impl HealManager {
|
|||||||
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
|
mrf_repair_notice_targets: Arc::new(StdMutex::new(HashMap::new())),
|
||||||
replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
replacement_recovery_anchors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||||
replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
replacement_recovery_blocked_sets: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
||||||
|
root_recovery: Arc::new(root_recovery::RootHealRecovery::default()),
|
||||||
|
force_start_shutdown: Mutex::new(()),
|
||||||
storage,
|
storage,
|
||||||
cancel_token: CancellationToken::new(),
|
cancel_token: CancellationToken::new(),
|
||||||
statistics: Arc::new(RwLock::new(HealStatistics::new())),
|
statistics: Arc::new(RwLock::new(HealStatistics::new())),
|
||||||
@@ -1393,6 +1419,23 @@ impl HealManager {
|
|||||||
"Heal manager starting"
|
"Heal manager starting"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Restore graceful-shutdown root responsibilities before automatic
|
||||||
|
// repair can admit overlapping work.
|
||||||
|
if let Err(error) = self.replay_root_heals().await {
|
||||||
|
// A missing owner or invalid root record must not block existing
|
||||||
|
// replacement recovery. Keep its file for a later restart after
|
||||||
|
// the owner is readable or the record has been repaired.
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::heal::manager",
|
||||||
|
event = EVENT_HEAL_MANAGER_STATE,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||||
|
state = "root_recovery_deferred",
|
||||||
|
error = %error,
|
||||||
|
"Root heal restart recovery deferred"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// start scheduler
|
// start scheduler
|
||||||
self.start_scheduler().await?;
|
self.start_scheduler().await?;
|
||||||
|
|
||||||
@@ -1430,6 +1473,7 @@ impl HealManager {
|
|||||||
|
|
||||||
/// Stop HealManager
|
/// Stop HealManager
|
||||||
pub async fn stop(&self) -> Result<()> {
|
pub async fn stop(&self) -> Result<()> {
|
||||||
|
let _force_start_guard = self.force_start_shutdown.lock().await;
|
||||||
info!(
|
info!(
|
||||||
target: "rustfs::heal::manager",
|
target: "rustfs::heal::manager",
|
||||||
event = EVENT_HEAL_MANAGER_STATE,
|
event = EVENT_HEAL_MANAGER_STATE,
|
||||||
@@ -1439,11 +1483,39 @@ impl HealManager {
|
|||||||
"Heal manager stopping"
|
"Heal manager stopping"
|
||||||
);
|
);
|
||||||
|
|
||||||
// cancel all tasks
|
// Keep scheduler, cancellation, and retry ownership stable until every
|
||||||
self.cancel_token.cancel();
|
// unfinished root traversal has a durable successor. A failed write
|
||||||
|
// must leave the manager running and the shutdown marker unclean.
|
||||||
// wait for all tasks to complete
|
|
||||||
let mut active_heals = self.active_heals.lock().await;
|
let mut active_heals = self.active_heals.lock().await;
|
||||||
|
let queue = self.heal_queue.lock().await;
|
||||||
|
let retrying = self.retrying_heals.lock().await;
|
||||||
|
for task in active_heals.values() {
|
||||||
|
if root_recovery::is_root_heal(&task.heal_type, task.source) {
|
||||||
|
if task.get_status().await == HealTaskStatus::Completed {
|
||||||
|
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
|
||||||
|
} else {
|
||||||
|
let mut request = match task.retry_request_with_remaining_timeout().await {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(Error::TaskTimeout) => {
|
||||||
|
let mut request = task.retry_request();
|
||||||
|
request.options.timeout = Some(Duration::ZERO);
|
||||||
|
request
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
request.retry_attempts = task.retry_attempts;
|
||||||
|
self.root_recovery.persist(&request).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for request in queue.requests().chain(retrying.values().map(|retrying| &retrying.request)) {
|
||||||
|
self.root_recovery.persist(request).await?;
|
||||||
|
}
|
||||||
|
self.cancel_token.cancel();
|
||||||
|
drop(retrying);
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
// cancel active workers after the durable handoff
|
||||||
for task in active_heals.values() {
|
for task in active_heals.values() {
|
||||||
if let Err(e) = task.cancel().await {
|
if let Err(e) = task.cancel().await {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1508,7 +1580,7 @@ impl HealManager {
|
|||||||
request: HealRequest,
|
request: HealRequest,
|
||||||
preserve_alias: bool,
|
preserve_alias: bool,
|
||||||
) -> Result<HealAdmissionReceipt> {
|
) -> Result<HealAdmissionReceipt> {
|
||||||
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, preserve_alias, None)
|
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, preserve_alias, true, None)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1544,19 +1616,43 @@ impl HealManager {
|
|||||||
request: HealRequest,
|
request: HealRequest,
|
||||||
mrf_notice_target: MrfRepairNoticeTarget,
|
mrf_notice_target: MrfRepairNoticeTarget,
|
||||||
) -> Result<HealAdmissionReceipt> {
|
) -> Result<HealAdmissionReceipt> {
|
||||||
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, true, Some(mrf_notice_target))
|
self.submit_heal_request_with_receipt_alias_and_mrf_notice(request, true, true, Some(mrf_notice_target))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn durable_mrf_repair_anchor(
|
||||||
|
&self,
|
||||||
|
intent: &rustfs_common::mrf_channel::MrfIntent,
|
||||||
|
) -> Option<rustfs_common::mrf_channel::MrfDurableRepairAnchor> {
|
||||||
|
match self.storage.mrf_bucket_incarnation_id(intent.bucket.as_ref()).await {
|
||||||
|
Ok(Some(bucket_incarnation_id)) => {
|
||||||
|
rustfs_common::mrf_channel::MrfDurableRepairAnchor::from_intent(intent, bucket_incarnation_id)
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn submit_heal_request_with_receipt_alias_and_mrf_notice(
|
async fn submit_heal_request_with_receipt_alias_and_mrf_notice(
|
||||||
&self,
|
&self,
|
||||||
request: HealRequest,
|
request: HealRequest,
|
||||||
preserve_alias: bool,
|
preserve_alias: bool,
|
||||||
|
accept_same_request_id_replay: bool,
|
||||||
mrf_notice_target: Option<MrfRepairNoticeTarget>,
|
mrf_notice_target: Option<MrfRepairNoticeTarget>,
|
||||||
) -> Result<HealAdmissionReceipt> {
|
) -> Result<HealAdmissionReceipt> {
|
||||||
let admission_start = Instant::now();
|
let admission_start = Instant::now();
|
||||||
let source = request.source;
|
let source = request.source;
|
||||||
let force_start = request.force_start;
|
let force_start = request.force_start;
|
||||||
|
// A forceStart must not retire an old durable owner if shutdown will
|
||||||
|
// reject its replacement. Hold the same gate through final admission.
|
||||||
|
let _force_start_guard = if source == HealRequestSource::Admin && force_start {
|
||||||
|
let guard = self.force_start_shutdown.lock().await;
|
||||||
|
if self.cancel_token.is_cancelled() {
|
||||||
|
return Err(Error::Other("Heal manager is stopping".to_string()));
|
||||||
|
}
|
||||||
|
Some(guard)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
// HS-06 forceStart semantics (admin only): MinIO stops the old task
|
// HS-06 forceStart semantics (admin only): MinIO stops the old task
|
||||||
// first and then starts the new one. Cancel any active admin task
|
// first and then starts the new one. Cancel any active admin task
|
||||||
// overlapping this request's path before entering admission, so the
|
// overlapping this request's path before entering admission, so the
|
||||||
@@ -1564,7 +1660,9 @@ impl HealManager {
|
|||||||
if request.source == HealRequestSource::Admin && request.force_start {
|
if request.source == HealRequestSource::Admin && request.force_start {
|
||||||
let overlapping: Vec<String> = {
|
let overlapping: Vec<String> = {
|
||||||
let active_heals = self.active_heals.lock().await;
|
let active_heals = self.active_heals.lock().await;
|
||||||
active_heals
|
let queue = self.heal_queue.lock().await;
|
||||||
|
let retrying = self.retrying_heals.lock().await;
|
||||||
|
let mut ids = active_heals
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(task_id, task)| {
|
.filter(|(task_id, task)| {
|
||||||
task.source == HealRequestSource::Admin
|
task.source == HealRequestSource::Admin
|
||||||
@@ -1572,7 +1670,17 @@ impl HealManager {
|
|||||||
&& *task_id != &request.id
|
&& *task_id != &request.id
|
||||||
})
|
})
|
||||||
.map(|(task_id, _)| task_id.clone())
|
.map(|(task_id, _)| task_id.clone())
|
||||||
.collect()
|
.collect::<Vec<_>>();
|
||||||
|
ids.extend(
|
||||||
|
queue
|
||||||
|
.requests()
|
||||||
|
.chain(retrying.values().map(|retrying| &retrying.request))
|
||||||
|
.filter(|pending| {
|
||||||
|
root_recovery::is_root_heal(&pending.heal_type, pending.source) && pending.id != request.id
|
||||||
|
})
|
||||||
|
.map(|pending| pending.id.clone()),
|
||||||
|
);
|
||||||
|
ids
|
||||||
};
|
};
|
||||||
for task_id in overlapping {
|
for task_id in overlapping {
|
||||||
match self.cancel_task(&task_id).await {
|
match self.cancel_task(&task_id).await {
|
||||||
@@ -1586,17 +1694,14 @@ impl HealManager {
|
|||||||
result = "force_start_cancelled_overlap",
|
result = "force_start_cancelled_overlap",
|
||||||
"Admin forceStart cancelled an overlapping heal task"
|
"Admin forceStart cancelled an overlapping heal task"
|
||||||
),
|
),
|
||||||
Err(err) => warn!(
|
Err(err) => return Err(err),
|
||||||
target: "rustfs::heal::manager",
|
}
|
||||||
event = EVENT_HEAL_QUEUE_ADMISSION,
|
}
|
||||||
component = LOG_COMPONENT_HEAL,
|
// A failed or timed-out replay may have only its durable owner
|
||||||
subsystem = LOG_SUBSYSTEM_MANAGER,
|
// left. Root responsibility overlaps every administrator path.
|
||||||
request_id = %request.id,
|
for pending in self.root_recovery.pending().await? {
|
||||||
cancelled_task_id = %task_id,
|
if pending.id != request.id {
|
||||||
error = %err,
|
self.cancel_task(&pending.id).await?;
|
||||||
result = "force_start_cancel_failed",
|
|
||||||
"Admin forceStart failed to cancel an overlapping heal task"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1609,10 +1714,75 @@ impl HealManager {
|
|||||||
// active -> retrying transitions can slip between duplicate checks.
|
// active -> retrying transitions can slip between duplicate checks.
|
||||||
let lock_phase_start = Instant::now();
|
let lock_phase_start = Instant::now();
|
||||||
let active_heals = self.active_heals.lock().await;
|
let active_heals = self.active_heals.lock().await;
|
||||||
|
if self.cancel_token.is_cancelled() {
|
||||||
|
return Err(Error::Other("Heal manager is stopping".to_string()));
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pause_duplicate_admission_after_active_lock(&request.id).await;
|
pause_duplicate_admission_after_active_lock(&request.id).await;
|
||||||
let mut queue = self.heal_queue.lock().await;
|
let mut queue = self.heal_queue.lock().await;
|
||||||
let retrying_heals = self.retrying_heals.lock().await;
|
let retrying_heals = self.retrying_heals.lock().await;
|
||||||
|
|
||||||
|
let request_id_admission = active_heals
|
||||||
|
.get(&request.id)
|
||||||
|
.map(|task| (request_matches_task(&request, task), "active"))
|
||||||
|
.or_else(|| {
|
||||||
|
queue
|
||||||
|
.requests()
|
||||||
|
.find(|queued| queued.id == request.id)
|
||||||
|
.map(|queued| (request_matches_request(&request, queued), "queued"))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
retrying_heals
|
||||||
|
.get(&request.id)
|
||||||
|
.map(|retrying| (request_matches_request(&request, &retrying.request), "retrying"))
|
||||||
|
});
|
||||||
|
if let Some((matches_existing, duplicate_state)) = request_id_admission {
|
||||||
|
let admission = if matches_existing {
|
||||||
|
if accept_same_request_id_replay {
|
||||||
|
HealAdmissionResult::Accepted
|
||||||
|
} else {
|
||||||
|
Self::duplicate_admission_for_request(&request, &config)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning)
|
||||||
|
};
|
||||||
|
if matches!(admission, HealAdmissionResult::Accepted | HealAdmissionResult::Merged)
|
||||||
|
&& let Some(target) = mrf_notice_target
|
||||||
|
{
|
||||||
|
let mut targets = lock_mrf_repair_notice_targets(&self.mrf_repair_notice_targets);
|
||||||
|
Self::insert_mrf_repair_notice_target(&mut targets, &request.id, target);
|
||||||
|
}
|
||||||
|
drop(retrying_heals);
|
||||||
|
drop(queue);
|
||||||
|
drop(active_heals);
|
||||||
|
let lock_phase = lock_phase_start.elapsed();
|
||||||
|
Self::record_admission_metric(request.source, admission, "duplicate");
|
||||||
|
self.record_admission_observation(HealAdmissionObservation {
|
||||||
|
source,
|
||||||
|
result: admission,
|
||||||
|
context: "duplicate",
|
||||||
|
force_start,
|
||||||
|
displaced: false,
|
||||||
|
start_duration: admission_start.elapsed(),
|
||||||
|
lock_phase,
|
||||||
|
});
|
||||||
|
debug!(
|
||||||
|
target: "rustfs::heal::manager",
|
||||||
|
event = EVENT_HEAL_QUEUE_ADMISSION,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||||
|
request_id = %request.id,
|
||||||
|
duplicate_state,
|
||||||
|
result = admission.result_label(),
|
||||||
|
reason = admission.reason_label(),
|
||||||
|
"Heal queue admission reused an existing request id"
|
||||||
|
);
|
||||||
|
return Ok(HealAdmissionReceipt {
|
||||||
|
result: admission,
|
||||||
|
task_id: request.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let duplicate = (!request.force_start).then(|| {
|
let duplicate = (!request.force_start).then(|| {
|
||||||
active_heal_for_dedup_key(&active_heals, &dedup_key)
|
active_heal_for_dedup_key(&active_heals, &dedup_key)
|
||||||
.map(|(task_id, _)| (task_id, "active"))
|
.map(|(task_id, _)| (task_id, "active"))
|
||||||
@@ -1819,7 +1989,10 @@ impl HealManager {
|
|||||||
|
|
||||||
/// Submit heal request.
|
/// Submit heal request.
|
||||||
pub async fn submit_heal_request(&self, request: HealRequest) -> Result<HealAdmissionResult> {
|
pub async fn submit_heal_request(&self, request: HealRequest) -> Result<HealAdmissionResult> {
|
||||||
Ok(self.submit_heal_request_with_receipt_and_alias(request, true).await?.result)
|
Ok(self
|
||||||
|
.submit_heal_request_with_receipt_alias_and_mrf_notice(request, true, false, None)
|
||||||
|
.await?
|
||||||
|
.result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get task status
|
/// Get task status
|
||||||
@@ -2048,6 +2221,7 @@ impl HealManager {
|
|||||||
{
|
{
|
||||||
let mut active_heals = self.active_heals.lock().await;
|
let mut active_heals = self.active_heals.lock().await;
|
||||||
if let Some(task) = active_heals.get(&canonical_task_id) {
|
if let Some(task) = active_heals.get(&canonical_task_id) {
|
||||||
|
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
|
||||||
task.cancel().await?;
|
task.cancel().await?;
|
||||||
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
|
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
|
||||||
publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await;
|
publish_completed_heal(&self.completed_heals, &self.task_aliases, &canonical_task_id, completed, true).await;
|
||||||
@@ -2071,6 +2245,11 @@ impl HealManager {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut retrying_heals = self.retrying_heals.lock().await;
|
let mut retrying_heals = self.retrying_heals.lock().await;
|
||||||
|
if let Some(retrying) = retrying_heals.get(&canonical_task_id) {
|
||||||
|
self.root_recovery
|
||||||
|
.remove(&canonical_task_id, &retrying.request.heal_type, retrying.request.source)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
if let Some(retrying) = retrying_heals.remove(&canonical_task_id) {
|
if let Some(retrying) = retrying_heals.remove(&canonical_task_id) {
|
||||||
retrying.cancel_token.cancel();
|
retrying.cancel_token.cancel();
|
||||||
drop(retrying_heals);
|
drop(retrying_heals);
|
||||||
@@ -2091,6 +2270,11 @@ impl HealManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut queue = self.heal_queue.lock().await;
|
let mut queue = self.heal_queue.lock().await;
|
||||||
|
if let Some(request) = queue.requests().find(|request| request.id == canonical_task_id) {
|
||||||
|
self.root_recovery
|
||||||
|
.remove(&request.id, &request.heal_type, request.source)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
if queue.remove_request_id(&canonical_task_id).is_some() {
|
if queue.remove_request_id(&canonical_task_id).is_some() {
|
||||||
publish_heal_queue_length(&queue);
|
publish_heal_queue_length(&queue);
|
||||||
info!(
|
info!(
|
||||||
@@ -2108,6 +2292,10 @@ impl HealManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(queue);
|
||||||
|
if self.root_recovery.cancel_pending(&canonical_task_id).await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
Err(Error::TaskNotFound {
|
Err(Error::TaskNotFound {
|
||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
})
|
})
|
||||||
@@ -2126,6 +2314,7 @@ impl HealManager {
|
|||||||
|
|
||||||
for task_id in &task_ids {
|
for task_id in &task_ids {
|
||||||
if let Some(task) = active_heals.get(task_id) {
|
if let Some(task) = active_heals.get(task_id) {
|
||||||
|
self.root_recovery.remove(&task.id, &task.heal_type, task.source).await?;
|
||||||
task.cancel().await?;
|
task.cancel().await?;
|
||||||
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
|
let completed = CompletedHealStatus::snapshot(task, HealTaskStatus::Cancelled).await;
|
||||||
publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await;
|
publish_completed_heal(&self.completed_heals, &self.task_aliases, task_id, completed, true).await;
|
||||||
@@ -2154,6 +2343,11 @@ impl HealManager {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for task_id in &task_ids {
|
for task_id in &task_ids {
|
||||||
|
if let Some(retrying) = retrying_heals.get(task_id) {
|
||||||
|
self.root_recovery
|
||||||
|
.remove(task_id, &retrying.request.heal_type, retrying.request.source)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
if let Some(retrying) = retrying_heals.remove(task_id) {
|
if let Some(retrying) = retrying_heals.remove(task_id) {
|
||||||
retrying.cancel_token.cancel();
|
retrying.cancel_token.cancel();
|
||||||
cancelled += 1;
|
cancelled += 1;
|
||||||
@@ -2176,6 +2370,14 @@ impl HealManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut queue = self.heal_queue.lock().await;
|
let mut queue = self.heal_queue.lock().await;
|
||||||
|
for request in queue
|
||||||
|
.requests()
|
||||||
|
.filter(|request| heal_type_matches_path(&request.heal_type, heal_path))
|
||||||
|
{
|
||||||
|
self.root_recovery
|
||||||
|
.remove(&request.id, &request.heal_type, request.source)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path));
|
let queued_cancelled = queue.remove_matching(|request| heal_type_matches_path(&request.heal_type, heal_path));
|
||||||
if !queued_cancelled.is_empty() {
|
if !queued_cancelled.is_empty() {
|
||||||
publish_heal_queue_length(&queue);
|
publish_heal_queue_length(&queue);
|
||||||
@@ -2187,6 +2389,13 @@ impl HealManager {
|
|||||||
self.remove_mrf_repair_notice_targets_for_task(&request.id);
|
self.remove_mrf_repair_notice_targets_for_task(&request.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if heal_type_matches_path(&HealType::Cluster, heal_path) {
|
||||||
|
for pending in self.root_recovery.pending().await? {
|
||||||
|
if self.root_recovery.cancel_pending(&pending.id).await? {
|
||||||
|
cancelled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if cancelled == 0 {
|
if cancelled == 0 {
|
||||||
return Err(Error::TaskNotFound {
|
return Err(Error::TaskNotFound {
|
||||||
task_id: heal_path.to_string(),
|
task_id: heal_path.to_string(),
|
||||||
@@ -2298,6 +2507,7 @@ impl std::fmt::Debug for HealManager {
|
|||||||
|
|
||||||
mod auto_scan;
|
mod auto_scan;
|
||||||
mod queue;
|
mod queue;
|
||||||
|
mod root_recovery;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod unclean_shutdown;
|
mod unclean_shutdown;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Graceful-shutdown handoff for administrator root heals. This namespace is
|
||||||
|
//! separate from erasure-set checkpoints and replacement generations, which
|
||||||
|
//! cannot represent a cluster traversal. One coordinator disk owns each
|
||||||
|
//! record; never create a fallback copy after an uncertain write or deletion.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::heal::storage_api::owner::{EcstoreConditionalFileUpdate, EcstoreDiskAPI, EcstoreDiskBytes};
|
||||||
|
use crate::heal::{DiskStore, RUSTFS_META_BUCKET};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// The metadata bucket already exists and its parent is durable. Creating a
|
||||||
|
// nested journal directory here would also require syncing every ancestor.
|
||||||
|
const ROOT_RECOVERY_PREFIX: &str = "root-heal-";
|
||||||
|
const ROOT_RECOVERY_SCHEMA: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct RootHealIntent {
|
||||||
|
schema: u32,
|
||||||
|
task_id: String,
|
||||||
|
#[serde(deserialize_with = "decode_options")]
|
||||||
|
options: HealOptions,
|
||||||
|
priority: HealPriority,
|
||||||
|
retry_attempts: u32,
|
||||||
|
created_at: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RootHealIntent {
|
||||||
|
fn from_request(request: &HealRequest) -> Self {
|
||||||
|
Self {
|
||||||
|
schema: ROOT_RECOVERY_SCHEMA,
|
||||||
|
task_id: request.id.clone(),
|
||||||
|
options: request.options.clone(),
|
||||||
|
priority: request.priority,
|
||||||
|
retry_attempts: request.retry_attempts,
|
||||||
|
created_at: request.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_request(self) -> HealRequest {
|
||||||
|
let mut request = HealRequest::new(HealType::Cluster, self.options, self.priority);
|
||||||
|
request.id = self.task_id;
|
||||||
|
request.source = HealRequestSource::Admin;
|
||||||
|
request.retry_attempts = self.retry_attempts;
|
||||||
|
request.created_at = self.created_at;
|
||||||
|
request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct RootHealRecovery {
|
||||||
|
mutation: Mutex<()>,
|
||||||
|
#[cfg(test)]
|
||||||
|
disks: Option<Vec<DiskStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_root_heal(heal_type: &HealType, source: HealRequestSource) -> bool {
|
||||||
|
source == HealRequestSource::Admin && matches!(heal_type, HealType::Cluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_options<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<HealOptions, D::Error> {
|
||||||
|
let value = serde_json::Value::deserialize(deserializer)?;
|
||||||
|
let object = value
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("root heal options must be an object"))?;
|
||||||
|
const FIELDS: &[&str] = &[
|
||||||
|
"scan_mode",
|
||||||
|
"remove_corrupted",
|
||||||
|
"recreate_missing",
|
||||||
|
"update_parity",
|
||||||
|
"recursive",
|
||||||
|
"dry_run",
|
||||||
|
"no_lock",
|
||||||
|
"timeout",
|
||||||
|
"pool_index",
|
||||||
|
"set_index",
|
||||||
|
];
|
||||||
|
if object.keys().any(|key| !FIELDS.contains(&key.as_str())) {
|
||||||
|
return Err(serde::de::Error::custom("unknown root heal recovery option"));
|
||||||
|
}
|
||||||
|
let options: HealOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
|
||||||
|
if options.no_lock {
|
||||||
|
return Err(serde::de::Error::custom("administrator root heal cannot skip namespace locking"));
|
||||||
|
}
|
||||||
|
Ok(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn intent_path(task_id: &str) -> Result<String> {
|
||||||
|
let parsed = uuid::Uuid::parse_str(task_id).map_err(|_| Error::Other("Invalid root heal recovery task id".to_string()))?;
|
||||||
|
if parsed.to_string() != task_id {
|
||||||
|
return Err(Error::Other("Noncanonical root heal recovery task id".to_string()));
|
||||||
|
}
|
||||||
|
Ok(format!("{ROOT_RECOVERY_PREFIX}{task_id}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_intent(task_id: &str, bytes: &[u8]) -> Result<RootHealIntent> {
|
||||||
|
let _ = intent_path(task_id)?;
|
||||||
|
let intent: RootHealIntent = serde_json::from_slice(bytes)
|
||||||
|
.map_err(|error| Error::Other(format!("Invalid root heal recovery record {task_id}: {error}")))?;
|
||||||
|
if intent.schema != ROOT_RECOVERY_SCHEMA || intent.task_id != task_id {
|
||||||
|
return Err(Error::Other(format!("Unsupported or mismatched root heal recovery record {task_id}")));
|
||||||
|
}
|
||||||
|
Ok(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RootHealRecovery {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn with_disks(disks: Vec<DiskStore>) -> Self {
|
||||||
|
Self {
|
||||||
|
mutation: Mutex::new(()),
|
||||||
|
disks: Some(disks),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disks(&self) -> Result<Vec<DiskStore>> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(disks) = &self.disks {
|
||||||
|
return Ok(disks.clone());
|
||||||
|
}
|
||||||
|
let map = local_disk_map_read().await;
|
||||||
|
if map.values().any(Option::is_none) {
|
||||||
|
return Err(Error::Other("Root heal recovery owner may be on an unavailable local disk".to_string()));
|
||||||
|
}
|
||||||
|
let mut disks = map.values().flatten().cloned().collect::<Vec<_>>();
|
||||||
|
disks.sort_by_key(|disk| EcstoreDiskAPI::endpoint(disk.as_ref()).to_string());
|
||||||
|
Ok(disks)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(disks: &[DiskStore], task_id: &str) -> Result<Option<(DiskStore, EcstoreDiskBytes)>> {
|
||||||
|
let path = intent_path(task_id)?;
|
||||||
|
let mut found = None;
|
||||||
|
for disk in disks {
|
||||||
|
// read_all reports FileNotFound even when the whole metadata
|
||||||
|
// volume is absent; that is an unknown owner, not empty state.
|
||||||
|
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
|
||||||
|
match EcstoreDiskAPI::read_all(disk.as_ref(), RUSTFS_META_BUCKET, &path).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
decode_intent(task_id, &bytes)?;
|
||||||
|
if found.is_some() {
|
||||||
|
return Err(Error::Other(format!("Multiple root heal recovery owners for {task_id}")));
|
||||||
|
}
|
||||||
|
found = Some((disk.clone(), bytes));
|
||||||
|
}
|
||||||
|
Err(DiskError::FileNotFound) => {}
|
||||||
|
Err(error) => return Err(Error::Disk(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn persist(&self, request: &HealRequest) -> Result<()> {
|
||||||
|
if !is_root_heal(&request.heal_type, request.source) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _guard = self.mutation.lock().await;
|
||||||
|
let disks = self.disks().await?;
|
||||||
|
let existing = Self::find(&disks, &request.id).await?;
|
||||||
|
let (disk, expected) = match existing {
|
||||||
|
Some((disk, bytes)) => (disk, Some(bytes)),
|
||||||
|
None => {
|
||||||
|
let disk = disks
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| Error::Other("No local disk available for root heal shutdown recovery".to_string()))?;
|
||||||
|
(disk, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if request.options.no_lock {
|
||||||
|
return Err(Error::Other("Administrator root heal cannot skip namespace locking".to_string()));
|
||||||
|
}
|
||||||
|
let bytes = serde_json::to_vec(&RootHealIntent::from_request(request))
|
||||||
|
.map_err(|error| Error::Other(format!("Serialize root heal recovery record: {error}")))?;
|
||||||
|
match EcstoreDiskAPI::compare_and_update_file(
|
||||||
|
disk.as_ref(),
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
&intent_path(&request.id)?,
|
||||||
|
expected,
|
||||||
|
Some(bytes.into()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
EcstoreConditionalFileUpdate::Updated => Ok(()),
|
||||||
|
_ => Err(Error::Other(format!("Root heal recovery record changed for {}", request.id))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn remove(&self, task_id: &str, heal_type: &HealType, source: HealRequestSource) -> Result<bool> {
|
||||||
|
if !is_root_heal(heal_type, source) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let _guard = self.mutation.lock().await;
|
||||||
|
let Some((disk, bytes)) = Self::find(&self.disks().await?, task_id).await? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
match EcstoreDiskAPI::compare_and_update_file(
|
||||||
|
disk.as_ref(),
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
&intent_path(task_id)?,
|
||||||
|
Some(bytes),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
EcstoreConditionalFileUpdate::Updated => Ok(true),
|
||||||
|
_ => Err(Error::Other(format!("Root heal recovery record changed while retiring {task_id}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn checkpoint_failed_execution(&self, task: &HealTask) -> Result<()> {
|
||||||
|
if !is_root_heal(&task.heal_type, task.source) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let remaining = match task.retry_request_with_remaining_timeout().await {
|
||||||
|
Ok(request) => request.options.timeout,
|
||||||
|
Err(Error::TaskTimeout) => Some(Duration::ZERO),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let _guard = self.mutation.lock().await;
|
||||||
|
let Some((disk, expected)) = Self::find(&self.disks().await?, &task.id).await? else {
|
||||||
|
// A first execution that failed has no restart handoff to update.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut intent = decode_intent(&task.id, &expected)?;
|
||||||
|
let mut expected_options = intent.options.clone();
|
||||||
|
expected_options.timeout = task.options.timeout;
|
||||||
|
if intent.created_at != task.created_at || intent.priority != task.priority || expected_options != task.options {
|
||||||
|
return Err(Error::Other(format!("Root heal recovery owner changed for {}", task.id)));
|
||||||
|
}
|
||||||
|
// A terminal timeout leaves no runtime owner for stop() to snapshot.
|
||||||
|
// Checkpoint its consumed budget before publishing terminal status;
|
||||||
|
// never refund time if an earlier checkpoint is already stricter.
|
||||||
|
intent.options.timeout = match (intent.options.timeout, remaining) {
|
||||||
|
(Some(previous), Some(remaining)) => Some(previous.min(remaining)),
|
||||||
|
(previous, remaining) => previous.or(remaining),
|
||||||
|
};
|
||||||
|
intent.retry_attempts = intent.retry_attempts.max(task.retry_attempts);
|
||||||
|
let bytes = serde_json::to_vec(&intent)
|
||||||
|
.map_err(|error| Error::Other(format!("Serialize root heal recovery checkpoint: {error}")))?;
|
||||||
|
match EcstoreDiskAPI::compare_and_update_file(
|
||||||
|
disk.as_ref(),
|
||||||
|
RUSTFS_META_BUCKET,
|
||||||
|
&intent_path(&task.id)?,
|
||||||
|
Some(expected),
|
||||||
|
Some(bytes.into()),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
EcstoreConditionalFileUpdate::Updated => Ok(()),
|
||||||
|
_ => Err(Error::Other(format!("Root heal recovery record changed while checkpointing {}", task.id))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn cancel_pending(&self, task_id: &str) -> Result<bool> {
|
||||||
|
if intent_path(task_id).is_err() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.remove(task_id, &HealType::Cluster, HealRequestSource::Admin).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn pending(&self) -> Result<Vec<HealRequest>> {
|
||||||
|
let _guard = self.mutation.lock().await;
|
||||||
|
let disks = self.disks().await?;
|
||||||
|
let mut ids = HashSet::new();
|
||||||
|
for disk in &disks {
|
||||||
|
EcstoreDiskAPI::stat_volume(disk.as_ref(), RUSTFS_META_BUCKET).await?;
|
||||||
|
let entries = match EcstoreDiskAPI::list_dir(disk.as_ref(), "", RUSTFS_META_BUCKET, "", -1).await {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(DiskError::FileNotFound) => continue,
|
||||||
|
Err(error) => return Err(Error::Disk(error)),
|
||||||
|
};
|
||||||
|
for entry in entries {
|
||||||
|
let Some(task_id) = entry
|
||||||
|
.strip_prefix(ROOT_RECOVERY_PREFIX)
|
||||||
|
.and_then(|entry| entry.strip_suffix(".json"))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let _ = intent_path(task_id)?;
|
||||||
|
ids.insert(task_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut requests = Vec::new();
|
||||||
|
for task_id in ids {
|
||||||
|
if let Some((_, bytes)) = Self::find(&disks, &task_id).await? {
|
||||||
|
requests.push(decode_intent(&task_id, &bytes)?.into_request());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requests.sort_by(|left, right| left.created_at.cmp(&right.created_at).then_with(|| left.id.cmp(&right.id)));
|
||||||
|
Ok(requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealManager {
|
||||||
|
pub(super) async fn replay_root_heals(&self) -> Result<()> {
|
||||||
|
// Decode every record before admitting anything. These are already
|
||||||
|
// accepted responsibilities, so restore distinct IDs even when their
|
||||||
|
// paths overlap or the configured admission capacity has changed.
|
||||||
|
let requests = self.root_recovery.pending().await?;
|
||||||
|
let active = self.active_heals.lock().await;
|
||||||
|
let mut queue = self.heal_queue.lock().await;
|
||||||
|
let retrying = self.retrying_heals.lock().await;
|
||||||
|
for mut request in requests {
|
||||||
|
request.force_start = true;
|
||||||
|
let existing = active
|
||||||
|
.get(&request.id)
|
||||||
|
.map(|task| request_matches_task(&request, task))
|
||||||
|
.or_else(|| {
|
||||||
|
queue
|
||||||
|
.requests()
|
||||||
|
.find(|queued| queued.id == request.id)
|
||||||
|
.map(|queued| request_matches_request(&request, queued))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
retrying
|
||||||
|
.get(&request.id)
|
||||||
|
.map(|retrying| request_matches_request(&request, &retrying.request))
|
||||||
|
});
|
||||||
|
match existing {
|
||||||
|
Some(true) => continue,
|
||||||
|
Some(false) => return Err(Error::Other(format!("Conflicting root heal recovery task {}", request.id))),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
queue.push(request);
|
||||||
|
}
|
||||||
|
publish_heal_queue_length(&queue);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ impl HealManager {
|
|||||||
let retrying_heals = self.retrying_heals.clone();
|
let retrying_heals = self.retrying_heals.clone();
|
||||||
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
|
let mrf_repair_notice_targets = self.mrf_repair_notice_targets.clone();
|
||||||
let replacement_recovery_anchors = self.replacement_recovery_anchors.clone();
|
let replacement_recovery_anchors = self.replacement_recovery_anchors.clone();
|
||||||
|
let root_recovery = self.root_recovery.clone();
|
||||||
let cancel_token = self.cancel_token.clone();
|
let cancel_token = self.cancel_token.clone();
|
||||||
let statistics = self.statistics.clone();
|
let statistics = self.statistics.clone();
|
||||||
let storage = self.storage.clone();
|
let storage = self.storage.clone();
|
||||||
@@ -59,6 +60,7 @@ impl HealManager {
|
|||||||
retrying_heals: &retrying_heals,
|
retrying_heals: &retrying_heals,
|
||||||
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
||||||
replacement_recovery_anchors: &replacement_recovery_anchors,
|
replacement_recovery_anchors: &replacement_recovery_anchors,
|
||||||
|
root_recovery: &root_recovery,
|
||||||
config: &config,
|
config: &config,
|
||||||
statistics: &statistics,
|
statistics: &statistics,
|
||||||
storage: &storage,
|
storage: &storage,
|
||||||
@@ -78,6 +80,7 @@ impl HealManager {
|
|||||||
retrying_heals: &retrying_heals,
|
retrying_heals: &retrying_heals,
|
||||||
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
mrf_repair_notice_targets: &mrf_repair_notice_targets,
|
||||||
replacement_recovery_anchors: &replacement_recovery_anchors,
|
replacement_recovery_anchors: &replacement_recovery_anchors,
|
||||||
|
root_recovery: &root_recovery,
|
||||||
config: &config,
|
config: &config,
|
||||||
statistics: &statistics,
|
statistics: &statistics,
|
||||||
storage: &storage,
|
storage: &storage,
|
||||||
@@ -106,6 +109,7 @@ impl HealManager {
|
|||||||
retrying_heals,
|
retrying_heals,
|
||||||
mrf_repair_notice_targets,
|
mrf_repair_notice_targets,
|
||||||
replacement_recovery_anchors,
|
replacement_recovery_anchors,
|
||||||
|
root_recovery,
|
||||||
config,
|
config,
|
||||||
statistics,
|
statistics,
|
||||||
storage,
|
storage,
|
||||||
@@ -117,6 +121,9 @@ impl HealManager {
|
|||||||
let config = config.read().await;
|
let config = config.read().await;
|
||||||
let mainline_pressure = Self::mainline_throttle_active(&config, workload_provider);
|
let mainline_pressure = Self::mainline_throttle_active(&config, workload_provider);
|
||||||
let mut active_heals_guard = active_heals.lock().await;
|
let mut active_heals_guard = active_heals.lock().await;
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
publish_active_heal_count(&active_heals_guard);
|
publish_active_heal_count(&active_heals_guard);
|
||||||
|
|
||||||
// Check if new heal tasks can be started
|
// Check if new heal tasks can be started
|
||||||
@@ -206,6 +213,7 @@ impl HealManager {
|
|||||||
let replacement_recovery_anchors_clone = replacement_recovery_anchors.clone();
|
let replacement_recovery_anchors_clone = replacement_recovery_anchors.clone();
|
||||||
let statistics_clone = statistics.clone();
|
let statistics_clone = statistics.clone();
|
||||||
let notify_clone = notify.clone();
|
let notify_clone = notify.clone();
|
||||||
|
let root_recovery_clone = root_recovery.clone();
|
||||||
let manager_cancel_token = cancel_token.clone();
|
let manager_cancel_token = cancel_token.clone();
|
||||||
let task_type_label_for_spawn = task_type_label.clone();
|
let task_type_label_for_spawn = task_type_label.clone();
|
||||||
let task_set_label_for_spawn = task_set_label.clone();
|
let task_set_label_for_spawn = task_set_label.clone();
|
||||||
@@ -294,6 +302,38 @@ impl HealManager {
|
|||||||
tests::pause_completed_retention_before_publish(&task_id, &completed_status).await;
|
tests::pause_completed_retention_before_publish(&task_id, &completed_status).await;
|
||||||
let mut active_heals_guard = active_heals_clone.lock().await;
|
let mut active_heals_guard = active_heals_clone.lock().await;
|
||||||
let owns_completion = active_heals_guard.contains_key(&task_id);
|
let owns_completion = active_heals_guard.contains_key(&task_id);
|
||||||
|
if owns_completion
|
||||||
|
&& result.is_ok()
|
||||||
|
&& let Err(error) = root_recovery_clone.remove(&task_id, &task.heal_type, task.source).await
|
||||||
|
{
|
||||||
|
// Keep the durable responsibility if retirement fails.
|
||||||
|
// Replaying a completed traversal is idempotent.
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::heal::manager",
|
||||||
|
event = EVENT_HEAL_SCHEDULER_STATE,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||||
|
task_id,
|
||||||
|
state = "root_recovery_retirement_failed",
|
||||||
|
error = %error,
|
||||||
|
"Failed to retire root heal recovery record"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if owns_completion
|
||||||
|
&& result.is_err()
|
||||||
|
&& let Err(error) = root_recovery_clone.checkpoint_failed_execution(&task).await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::heal::manager",
|
||||||
|
event = EVENT_HEAL_SCHEDULER_STATE,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_MANAGER,
|
||||||
|
task_id,
|
||||||
|
state = "root_recovery_checkpoint_failed",
|
||||||
|
error = %error,
|
||||||
|
"Failed to checkpoint root heal recovery execution budget"
|
||||||
|
);
|
||||||
|
}
|
||||||
let cancelled_completion = if owns_completion {
|
let cancelled_completion = if owns_completion {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -733,23 +773,30 @@ pub(super) fn mrf_verified_repair_event_for_target(
|
|||||||
HealObjectDisposition::AuthoritativelyAbsent => MrfVerifiedRepairDisposition::AuthoritativelyAbsent,
|
HealObjectDisposition::AuthoritativelyAbsent => MrfVerifiedRepairDisposition::AuthoritativelyAbsent,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
if target.kind != MrfKind::PartialWrite {
|
let expected_kind = match target.kind {
|
||||||
return None;
|
MrfKind::DecodeFailure => HealObjectKind::Decode,
|
||||||
}
|
MrfKind::MetadataCorruption => HealObjectKind::Metadata,
|
||||||
let expected_kind = HealObjectKind::Object;
|
MrfKind::PartialWrite => HealObjectKind::Object,
|
||||||
|
};
|
||||||
if outcome.identity.kind != expected_kind
|
if outcome.identity.kind != expected_kind
|
||||||
|| outcome.identity.bucket.as_str() != target.bucket.as_ref()
|
|| outcome.identity.bucket.as_str() != target.bucket.as_ref()
|
||||||
|| outcome.identity.object.as_str() != target.object.as_ref()
|
|| outcome.identity.object.as_str() != target.object.as_ref()
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let version_id = target.version_id.filter(|bytes| *bytes != [0; 16]);
|
let version_id = (!matches!(target.kind, MrfKind::MetadataCorruption))
|
||||||
|
.then_some(target.version_id)
|
||||||
|
.flatten()
|
||||||
|
.filter(|bytes| *bytes != [0; 16]);
|
||||||
let expected_version = version_id.map(|bytes| uuid::Uuid::from_bytes(bytes).to_string());
|
let expected_version = version_id.map(|bytes| uuid::Uuid::from_bytes(bytes).to_string());
|
||||||
if outcome.identity.version_id != expected_version {
|
if outcome.identity.version_id != expected_version {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let expected_pool = target.scope.and_then(|scope| usize::try_from(scope.pool_index).ok());
|
let scope = (!matches!(target.kind, MrfKind::MetadataCorruption))
|
||||||
let expected_set = target.scope.and_then(|scope| usize::try_from(scope.set_index).ok());
|
.then_some(target.scope)
|
||||||
|
.flatten();
|
||||||
|
let expected_pool = scope.and_then(|scope| usize::try_from(scope.pool_index).ok());
|
||||||
|
let expected_set = scope.and_then(|scope| usize::try_from(scope.set_index).ok());
|
||||||
if outcome.identity.pool_index != expected_pool || outcome.identity.set_index != expected_set {
|
if outcome.identity.pool_index != expected_pool || outcome.identity.set_index != expected_set {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -759,7 +806,7 @@ pub(super) fn mrf_verified_repair_event_for_target(
|
|||||||
bucket: target.bucket.clone(),
|
bucket: target.bucket.clone(),
|
||||||
object: target.object.clone(),
|
object: target.object.clone(),
|
||||||
version_id,
|
version_id,
|
||||||
scope: target.scope,
|
scope,
|
||||||
lease: target.lease,
|
lease: target.lease,
|
||||||
bucket_incarnation_id,
|
bucket_incarnation_id,
|
||||||
disposition,
|
disposition,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use rustfs_madmin::heal_commands::HealResultItem;
|
|||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
mod root_recovery;
|
||||||
mod running_mainline;
|
mod running_mainline;
|
||||||
|
|
||||||
use super::super::{DiskOption, DiskStore, Endpoint, new_disk, storage_api::status::BucketInfo};
|
use super::super::{DiskOption, DiskStore, Endpoint, new_disk, storage_api::status::BucketInfo};
|
||||||
@@ -94,6 +95,7 @@ async fn process_manager_queue_once(manager: &HealManager) {
|
|||||||
retrying_heals: &manager.retrying_heals,
|
retrying_heals: &manager.retrying_heals,
|
||||||
mrf_repair_notice_targets: &manager.mrf_repair_notice_targets,
|
mrf_repair_notice_targets: &manager.mrf_repair_notice_targets,
|
||||||
replacement_recovery_anchors: &manager.replacement_recovery_anchors,
|
replacement_recovery_anchors: &manager.replacement_recovery_anchors,
|
||||||
|
root_recovery: &manager.root_recovery,
|
||||||
config: &manager.config,
|
config: &manager.config,
|
||||||
statistics: &manager.statistics,
|
statistics: &manager.statistics,
|
||||||
storage: &manager.storage,
|
storage: &manager.storage,
|
||||||
@@ -1142,18 +1144,48 @@ fn mrf_verified_repair_event_requires_positive_exact_identity() {
|
|||||||
assert_eq!(event.bucket_incarnation_id, incarnation);
|
assert_eq!(event.bucket_incarnation_id, incarnation);
|
||||||
assert_eq!(event.disposition, MrfVerifiedRepairDisposition::Repaired);
|
assert_eq!(event.disposition, MrfVerifiedRepairDisposition::Repaired);
|
||||||
|
|
||||||
assert!(
|
let decode_target = MrfRepairNoticeTarget {
|
||||||
mrf_verified_repair_event_for_target(
|
|
||||||
&MrfRepairNoticeTarget {
|
|
||||||
kind: MrfKind::DecodeFailure,
|
kind: MrfKind::DecodeFailure,
|
||||||
..target.clone()
|
..target.clone()
|
||||||
|
};
|
||||||
|
let decode_outcome = HealObjectOutcome {
|
||||||
|
identity: HealObjectIdentity {
|
||||||
|
kind: HealObjectKind::Decode,
|
||||||
|
..matching.identity.clone()
|
||||||
},
|
},
|
||||||
&matching
|
..matching.clone()
|
||||||
)
|
};
|
||||||
.is_none(),
|
let decode_event =
|
||||||
"only receipt-producing partial-write object heals can publish verified events today"
|
mrf_verified_repair_event_for_target(&decode_target, &decode_outcome).expect("decode repairs publish exact proofs");
|
||||||
|
assert_eq!(decode_event.kind, MrfKind::DecodeFailure);
|
||||||
|
assert_eq!(
|
||||||
|
decode_event.scope,
|
||||||
|
Some(MrfScope {
|
||||||
|
pool_index: 1,
|
||||||
|
set_index: 2
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let metadata_target = MrfRepairNoticeTarget {
|
||||||
|
kind: MrfKind::MetadataCorruption,
|
||||||
|
..target.clone()
|
||||||
|
};
|
||||||
|
let metadata_outcome = HealObjectOutcome {
|
||||||
|
identity: HealObjectIdentity {
|
||||||
|
kind: HealObjectKind::Metadata,
|
||||||
|
version_id: None,
|
||||||
|
pool_index: None,
|
||||||
|
set_index: None,
|
||||||
|
..matching.identity.clone()
|
||||||
|
},
|
||||||
|
..matching.clone()
|
||||||
|
};
|
||||||
|
let metadata_event =
|
||||||
|
mrf_verified_repair_event_for_target(&metadata_target, &metadata_outcome).expect("metadata repairs publish exact proofs");
|
||||||
|
assert_eq!(metadata_event.kind, MrfKind::MetadataCorruption);
|
||||||
|
assert_eq!(metadata_event.version_id, None);
|
||||||
|
assert_eq!(metadata_event.scope, None);
|
||||||
|
|
||||||
for rejected in [
|
for rejected in [
|
||||||
HealObjectOutcome {
|
HealObjectOutcome {
|
||||||
disposition: HealObjectDisposition::Unknown,
|
disposition: HealObjectDisposition::Unknown,
|
||||||
@@ -4537,6 +4569,48 @@ async fn test_force_start_marks_dedup_key_for_future_duplicates() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn same_request_id_replay_reuses_existing_task_without_force_start_duplication() {
|
||||||
|
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||||
|
let manager = HealManager::new(storage, None);
|
||||||
|
|
||||||
|
let mut original = admin_prefix_request("bucket", "logs/");
|
||||||
|
original.force_start = true;
|
||||||
|
let original_id = original.id.clone();
|
||||||
|
let accepted = manager
|
||||||
|
.submit_heal_request_with_receipt(original.clone())
|
||||||
|
.await
|
||||||
|
.expect("original forceStart request should queue");
|
||||||
|
assert_eq!(accepted.result, HealAdmissionResult::Accepted);
|
||||||
|
assert_eq!(accepted.task_id, original_id);
|
||||||
|
|
||||||
|
let replayed = manager
|
||||||
|
.submit_heal_request_with_receipt(original.clone())
|
||||||
|
.await
|
||||||
|
.expect("same request id and payload should reuse the existing task");
|
||||||
|
assert_eq!(replayed.result, HealAdmissionResult::Accepted);
|
||||||
|
assert_eq!(replayed.task_id, original_id);
|
||||||
|
assert_eq!(
|
||||||
|
manager.get_queue_length().await,
|
||||||
|
1,
|
||||||
|
"exact forceStart replay must not create a second queued task"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut changed = original;
|
||||||
|
changed.options.remove_corrupted = true;
|
||||||
|
let changed = manager
|
||||||
|
.submit_heal_request_with_receipt(changed)
|
||||||
|
.await
|
||||||
|
.expect("same request id with a changed payload should fail closed");
|
||||||
|
assert_eq!(changed.result, HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning));
|
||||||
|
assert_eq!(changed.task_id, original_id);
|
||||||
|
assert_eq!(
|
||||||
|
manager.get_queue_length().await,
|
||||||
|
1,
|
||||||
|
"same-id conflict must not displace or duplicate the original task"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_running_heal_set_counts_groups_set_scoped_tasks() {
|
fn test_running_heal_set_counts_groups_set_scoped_tasks() {
|
||||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
let storage: Arc<dyn HealStorageAPI> = Arc::new(MockStorage);
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::super::root_recovery::RootHealRecovery;
|
||||||
|
use super::*;
|
||||||
|
use crate::heal::RUSTFS_META_BUCKET;
|
||||||
|
|
||||||
|
async fn recovery_disk() -> (TempDir, DiskStore) {
|
||||||
|
let temp = TempDir::new().expect("temporary root recovery disk");
|
||||||
|
let endpoint = Endpoint::try_from(temp.path().to_string_lossy().as_ref()).expect("disk endpoint");
|
||||||
|
let disk = new_disk(
|
||||||
|
&endpoint,
|
||||||
|
&DiskOption {
|
||||||
|
cleanup: false,
|
||||||
|
health_check: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("local recovery disk");
|
||||||
|
match disk.make_volume(RUSTFS_META_BUCKET).await {
|
||||||
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||||
|
Err(error) => panic!("metadata volume: {error}"),
|
||||||
|
}
|
||||||
|
(temp, disk)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovery_manager(disks: Vec<DiskStore>) -> HealManager {
|
||||||
|
let mut manager = HealManager::new(
|
||||||
|
Arc::new(MockStorage),
|
||||||
|
Some(HealConfig {
|
||||||
|
enable_auto_heal: false,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
manager.root_recovery = Arc::new(RootHealRecovery::with_disks(disks));
|
||||||
|
manager
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_request() -> HealRequest {
|
||||||
|
let mut request = HealRequest::new(HealType::Cluster, HealOptions::default(), HealPriority::High);
|
||||||
|
request.source = HealRequestSource::Admin;
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn active_root(manager: &HealManager, request: HealRequest) -> Arc<HealTask> {
|
||||||
|
let task = Arc::new(HealTask::from_request(request, manager.storage.clone()));
|
||||||
|
*task.status.write().await = HealTaskStatus::Running;
|
||||||
|
task.progress.write().await.update_object_progress(1, 1, 0, 0, 128);
|
||||||
|
manager.active_heals.lock().await.insert(task.id.clone(), task.clone());
|
||||||
|
task
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_shutdown_restart_replays_same_id_and_success_retires_intent() {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let mut request = root_request();
|
||||||
|
request.options.recursive = true;
|
||||||
|
let task = active_root(&manager, request.clone()).await;
|
||||||
|
manager.stop().await.expect("durable shutdown handoff");
|
||||||
|
assert!(task.cancel_token.is_cancelled());
|
||||||
|
drop(manager);
|
||||||
|
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted.replay_root_heals().await.expect("replay durable root");
|
||||||
|
restarted.replay_root_heals().await.expect("replay is idempotent");
|
||||||
|
assert_eq!(restarted.get_queue_length().await, 1);
|
||||||
|
let restored = restarted
|
||||||
|
.heal_queue
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.requests()
|
||||||
|
.next()
|
||||||
|
.cloned()
|
||||||
|
.expect("restored request");
|
||||||
|
assert_eq!(restored.id, request.id);
|
||||||
|
assert_eq!(restored.options, request.options);
|
||||||
|
assert_eq!(restored.priority, request.priority);
|
||||||
|
assert_eq!(restored.retry_attempts, request.retry_attempts);
|
||||||
|
assert_eq!(restored.created_at, request.created_at);
|
||||||
|
|
||||||
|
process_manager_queue_once(&restarted).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Completed))
|
||||||
|
&& !restarted.active_heals.lock().await.contains_key(&request.id)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("restored root executes successfully");
|
||||||
|
assert!(restarted.root_recovery.pending().await.expect("read completion").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_explicit_cancel_covers_active_queued_retrying_and_durable_only() {
|
||||||
|
for state in ["active", "queued", "retrying", "durable_only", "root_path"] {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let request = root_request();
|
||||||
|
manager.root_recovery.persist(&request).await.expect("durable responsibility");
|
||||||
|
match state {
|
||||||
|
"active" => {
|
||||||
|
active_root(&manager, request.clone()).await;
|
||||||
|
}
|
||||||
|
"queued" => {
|
||||||
|
manager.replay_root_heals().await.expect("queued recovery");
|
||||||
|
}
|
||||||
|
"retrying" => {
|
||||||
|
insert_retrying_request(&manager, request.clone()).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if state == "root_path" {
|
||||||
|
assert_eq!(manager.cancel_tasks_for_path("").await.expect("cancel durable root path"), 1);
|
||||||
|
} else {
|
||||||
|
manager.cancel_task(&request.id).await.expect("cancel root responsibility");
|
||||||
|
}
|
||||||
|
drop(manager);
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted
|
||||||
|
.replay_root_heals()
|
||||||
|
.await
|
||||||
|
.expect("restart after explicit cancellation");
|
||||||
|
assert_eq!(restarted.get_queue_length().await, 0, "state={state}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_force_start_cancels_durable_only_responsibility() {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let old = root_request();
|
||||||
|
manager
|
||||||
|
.root_recovery
|
||||||
|
.persist(&old)
|
||||||
|
.await
|
||||||
|
.expect("old terminal responsibility");
|
||||||
|
let mut new = root_request();
|
||||||
|
new.force_start = true;
|
||||||
|
assert_eq!(
|
||||||
|
manager
|
||||||
|
.submit_heal_request(new.clone())
|
||||||
|
.await
|
||||||
|
.expect("force start replacement"),
|
||||||
|
HealAdmissionResult::Accepted
|
||||||
|
);
|
||||||
|
assert!(manager.root_recovery.pending().await.expect("old owner retired").is_empty());
|
||||||
|
manager.stop().await.expect("persist new root only");
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted.replay_root_heals().await.expect("restart replacement");
|
||||||
|
let ids = restarted
|
||||||
|
.heal_queue
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.requests()
|
||||||
|
.map(|request| request.id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(ids, [new.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_force_start_replaces_fresh_queued_and_retrying_admin_roots() {
|
||||||
|
for retrying in [false, true] {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let old = root_request();
|
||||||
|
if retrying {
|
||||||
|
insert_retrying_request(&manager, old.clone()).await;
|
||||||
|
} else {
|
||||||
|
manager.submit_heal_request(old.clone()).await.expect("queue original root");
|
||||||
|
}
|
||||||
|
assert!(manager.root_recovery.pending().await.expect("not handed off yet").is_empty());
|
||||||
|
let mut new = root_request();
|
||||||
|
new.force_start = true;
|
||||||
|
assert_eq!(
|
||||||
|
manager.submit_heal_request(new.clone()).await.expect("force replacement"),
|
||||||
|
HealAdmissionResult::Accepted
|
||||||
|
);
|
||||||
|
manager.stop().await.expect("handoff only the new responsibility");
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted.replay_root_heals().await.expect("restart after forceStart");
|
||||||
|
let ids = restarted
|
||||||
|
.heal_queue
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.requests()
|
||||||
|
.map(|request| request.id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(ids, [new.id], "retrying={retrying}; old={}", old.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_invalid_records_are_retained_without_partial_replay() {
|
||||||
|
for kind in ["truncated", "schema", "identity", "option", "no_lock"] {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let valid = root_request();
|
||||||
|
let invalid = root_request();
|
||||||
|
manager.root_recovery.persist(&valid).await.expect("valid root record");
|
||||||
|
manager
|
||||||
|
.root_recovery
|
||||||
|
.persist(&invalid)
|
||||||
|
.await
|
||||||
|
.expect("record before corruption");
|
||||||
|
let path = format!("root-heal-{}.json", invalid.id);
|
||||||
|
let original = disk.read_all(RUSTFS_META_BUCKET, &path).await.expect("read root record");
|
||||||
|
let mut value: serde_json::Value = serde_json::from_slice(&original).expect("record JSON");
|
||||||
|
match kind {
|
||||||
|
"schema" => value["schema"] = 2.into(),
|
||||||
|
"identity" => value["task_id"] = valid.id.clone().into(),
|
||||||
|
"option" => value["options"]["future_delete_mode"] = true.into(),
|
||||||
|
"no_lock" => value["options"]["no_lock"] = true.into(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let bytes = if kind == "truncated" {
|
||||||
|
b"{".to_vec()
|
||||||
|
} else {
|
||||||
|
serde_json::to_vec(&value).expect("modified record")
|
||||||
|
};
|
||||||
|
disk.write_all(RUSTFS_META_BUCKET, &path, bytes.clone().into())
|
||||||
|
.await
|
||||||
|
.expect("inject bad record");
|
||||||
|
assert!(manager.replay_root_heals().await.is_err(), "kind={kind}");
|
||||||
|
assert_eq!(manager.get_queue_length().await, 0, "no partial admission for {kind}");
|
||||||
|
assert_eq!(
|
||||||
|
disk.read_all(RUSTFS_META_BUCKET, &path)
|
||||||
|
.await
|
||||||
|
.expect("bad record retained")
|
||||||
|
.as_ref(),
|
||||||
|
bytes
|
||||||
|
);
|
||||||
|
let mut forced = root_request();
|
||||||
|
forced.force_start = true;
|
||||||
|
assert!(
|
||||||
|
manager.submit_heal_request(forced).await.is_err(),
|
||||||
|
"forceStart must not discard unknown state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_failed_handoff_keeps_runtime_owner_and_does_not_try_another_disk() {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let (unavailable_temp, unavailable) = recovery_disk().await;
|
||||||
|
std::fs::remove_dir_all(unavailable_temp.path().join(RUSTFS_META_BUCKET)).expect("make owner volume unavailable");
|
||||||
|
let manager = recovery_manager(vec![unavailable, disk.clone()]);
|
||||||
|
let task = active_root(&manager, root_request()).await;
|
||||||
|
assert!(manager.stop().await.is_err());
|
||||||
|
assert!(
|
||||||
|
manager.cancel_task(&task.id).await.is_err(),
|
||||||
|
"missing owner cannot acknowledge cancellation"
|
||||||
|
);
|
||||||
|
assert!(!manager.cancel_token.is_cancelled());
|
||||||
|
assert!(!task.cancel_token.is_cancelled());
|
||||||
|
assert!(manager.active_heals.lock().await.contains_key(&task.id));
|
||||||
|
assert!(
|
||||||
|
RootHealRecovery::with_disks(vec![disk])
|
||||||
|
.pending()
|
||||||
|
.await
|
||||||
|
.expect("other disk remains empty")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_shutdown_fences_new_admission_and_preserves_later_cancellation() {
|
||||||
|
for operation_kind in ["submit", "force_start", "cancel"] {
|
||||||
|
let cancel = operation_kind == "cancel";
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = Arc::new(recovery_manager(vec![disk.clone()]));
|
||||||
|
let request = root_request();
|
||||||
|
active_root(&manager, request.clone()).await;
|
||||||
|
let queue = manager.heal_queue.lock().await;
|
||||||
|
let stopping = manager.clone();
|
||||||
|
let stop = tokio::spawn(async move { stopping.stop().await });
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if manager.active_heals.try_lock().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("shutdown owns active lock while waiting for queue");
|
||||||
|
let concurrent = manager.clone();
|
||||||
|
let operation = tokio::spawn(async move {
|
||||||
|
if cancel {
|
||||||
|
concurrent.cancel_task(&request.id).await
|
||||||
|
} else {
|
||||||
|
let mut new = root_request();
|
||||||
|
new.force_start = operation_kind == "force_start";
|
||||||
|
concurrent.submit_heal_request(new).await.map(|_| ())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
drop(queue);
|
||||||
|
stop.await.expect("shutdown task").expect("durable shutdown");
|
||||||
|
let result = operation.await.expect("concurrent operation");
|
||||||
|
assert_eq!(result.is_ok(), cancel, "operation={operation_kind}");
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted.replay_root_heals().await.expect("read final responsibility");
|
||||||
|
assert_eq!(restarted.get_queue_length().await, usize::from(!cancel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_exhausted_timeout_is_not_reset_by_restart() {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let mut request = root_request();
|
||||||
|
request.options.timeout = Some(Duration::from_secs(10));
|
||||||
|
let task = active_root(&manager, request.clone()).await;
|
||||||
|
task.set_execution_elapsed_for_test(Duration::from_secs(11)).await;
|
||||||
|
manager.stop().await.expect("persist exhausted execution budget");
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted.replay_root_heals().await.expect("restore bounded request");
|
||||||
|
assert_eq!(
|
||||||
|
restarted
|
||||||
|
.heal_queue
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.requests()
|
||||||
|
.next()
|
||||||
|
.expect("restored root")
|
||||||
|
.options
|
||||||
|
.timeout,
|
||||||
|
Some(Duration::ZERO)
|
||||||
|
);
|
||||||
|
process_manager_queue_once(&restarted).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if matches!(restarted.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("exhausted request stays timed out");
|
||||||
|
restarted
|
||||||
|
.cancel_task(&request.id)
|
||||||
|
.await
|
||||||
|
.expect("timeout responsibility remains cancellable");
|
||||||
|
assert!(restarted.root_recovery.pending().await.expect("retired timeout").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_shutdown_preserves_remaining_execution_budget() {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let mut request = root_request();
|
||||||
|
request.options.timeout = Some(Duration::from_secs(60));
|
||||||
|
let task = active_root(&manager, request).await;
|
||||||
|
task.set_execution_elapsed_for_test(Duration::from_secs(20)).await;
|
||||||
|
manager.stop().await.expect("handoff with consumed execution time");
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted.replay_root_heals().await.expect("restore remaining budget");
|
||||||
|
let queue = restarted.heal_queue.lock().await;
|
||||||
|
let remaining = queue
|
||||||
|
.requests()
|
||||||
|
.next()
|
||||||
|
.expect("restored root")
|
||||||
|
.options
|
||||||
|
.timeout
|
||||||
|
.expect("remaining timeout");
|
||||||
|
assert!(remaining <= Duration::from_secs(40), "elapsed execution must not be refunded");
|
||||||
|
assert!(
|
||||||
|
remaining >= Duration::from_secs(30),
|
||||||
|
"shutdown fixture should retain most of its remaining budget"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_force_start_after_shutdown_does_not_retire_original_owner() {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let old = root_request();
|
||||||
|
active_root(&manager, old.clone()).await;
|
||||||
|
manager.stop().await.expect("handoff original root");
|
||||||
|
let mut new = root_request();
|
||||||
|
new.force_start = true;
|
||||||
|
assert!(manager.submit_heal_request(new).await.is_err());
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted.replay_root_heals().await.expect("original responsibility remains");
|
||||||
|
let ids = restarted
|
||||||
|
.heal_queue
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.requests()
|
||||||
|
.map(|request| request.id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(ids, [old.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_recovery_terminal_timeout_updates_only_existing_journal_before_second_restart() {
|
||||||
|
for durable in [false, true] {
|
||||||
|
let (_temp, disk) = recovery_disk().await;
|
||||||
|
let manager = recovery_manager(vec![disk.clone()]);
|
||||||
|
let mut request = root_request();
|
||||||
|
request.options.timeout = Some(Duration::from_nanos(1));
|
||||||
|
if durable {
|
||||||
|
manager
|
||||||
|
.root_recovery
|
||||||
|
.persist(&request)
|
||||||
|
.await
|
||||||
|
.expect("persist nonzero execution budget");
|
||||||
|
manager.replay_root_heals().await.expect("first restart");
|
||||||
|
} else {
|
||||||
|
manager
|
||||||
|
.submit_heal_request(request.clone())
|
||||||
|
.await
|
||||||
|
.expect("first root execution");
|
||||||
|
}
|
||||||
|
process_manager_queue_once(&manager).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if matches!(manager.get_task_status(&request.id).await, Ok(HealTaskStatus::Timeout))
|
||||||
|
&& !manager.active_heals.lock().await.contains_key(&request.id)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("real execution exhausts a nonzero budget");
|
||||||
|
assert!(!manager.active_heals.lock().await.contains_key(&request.id));
|
||||||
|
let restarted = recovery_manager(vec![disk]);
|
||||||
|
restarted
|
||||||
|
.replay_root_heals()
|
||||||
|
.await
|
||||||
|
.expect("second restart after terminal timeout");
|
||||||
|
let queue = restarted.heal_queue.lock().await;
|
||||||
|
if durable {
|
||||||
|
assert_eq!(
|
||||||
|
queue
|
||||||
|
.requests()
|
||||||
|
.next()
|
||||||
|
.expect("remaining timeout responsibility")
|
||||||
|
.options
|
||||||
|
.timeout,
|
||||||
|
Some(Duration::ZERO)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert!(queue.is_empty(), "terminal failure must not create a new durable responsibility");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,10 @@ pub mod task;
|
|||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use storage_api::owner::{
|
use storage_api::owner::{
|
||||||
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_RUSTFS_META_BUCKET,
|
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_POOL_META_NAME,
|
||||||
EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError, EcstoreDiskOption,
|
ECSTORE_RUSTFS_META_BUCKET, EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes,
|
||||||
EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType, EcstoreStorageError, EcstoreStore, ObjectIO,
|
EcstoreDiskError, EcstoreDiskOption, EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType,
|
||||||
ObjectOperations, ecstore_local_disk_map_read, ecstore_new_disk,
|
EcstoreStorageError, EcstoreStore, ObjectIO, ObjectOperations, ecstore_local_disk_map_read, ecstore_new_disk,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use erasure_healer::ErasureSetHealer;
|
pub use erasure_healer::ErasureSetHealer;
|
||||||
@@ -41,6 +41,7 @@ pub use task::{HealOptions, HealPriority, HealRequest, HealTask, HealType};
|
|||||||
pub(crate) const DATA_USAGE_CACHE_NAME: &str = ECSTORE_DATA_USAGE_CACHE_NAME;
|
pub(crate) const DATA_USAGE_CACHE_NAME: &str = ECSTORE_DATA_USAGE_CACHE_NAME;
|
||||||
pub(crate) const BUCKET_META_PREFIX: &str = ECSTORE_BUCKET_META_PREFIX;
|
pub(crate) const BUCKET_META_PREFIX: &str = ECSTORE_BUCKET_META_PREFIX;
|
||||||
pub(crate) const RUSTFS_META_BUCKET: &str = ECSTORE_RUSTFS_META_BUCKET;
|
pub(crate) const RUSTFS_META_BUCKET: &str = ECSTORE_RUSTFS_META_BUCKET;
|
||||||
|
pub(crate) const POOL_META_NAME: &str = ECSTORE_POOL_META_NAME;
|
||||||
|
|
||||||
/// Marker written to every local disk while the process runs; removed by
|
/// Marker written to every local disk while the process runs; removed by
|
||||||
/// [`clear_unclean_shutdown_markers`] on graceful shutdown. Finding it at
|
/// [`clear_unclean_shutdown_markers`] on graceful shutdown. Finding it at
|
||||||
|
|||||||
+1400
-35
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -110,7 +110,8 @@ impl HealObjectReceipt {
|
|||||||
&& self.identity.version_id == expected.version_id
|
&& self.identity.version_id == expected.version_id
|
||||||
&& self.identity.pool_index == expected.pool_index
|
&& self.identity.pool_index == expected.pool_index
|
||||||
&& self.identity.set_index == expected.set_index
|
&& self.identity.set_index == expected.set_index
|
||||||
&& self.identity.bucket_incarnation_id.is_some()
|
&& self.identity.bucket_incarnation_id == expected.bucket_incarnation_id
|
||||||
|
&& expected.bucket_incarnation_id.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,21 +497,31 @@ mod canonical_outcome_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn positive_receipt_requires_exact_identity_and_bucket_incarnation() {
|
fn positive_receipt_requires_exact_identity_and_bucket_incarnation() {
|
||||||
let expected = item(HealObjectDisposition::Unknown).identity;
|
let incarnation = Uuid::new_v4();
|
||||||
|
let expected = HealObjectIdentity {
|
||||||
|
bucket_incarnation_id: Some(incarnation),
|
||||||
|
..item(HealObjectDisposition::Unknown).identity
|
||||||
|
};
|
||||||
let mut receipt = HealObjectReceipt {
|
let mut receipt = HealObjectReceipt {
|
||||||
identity: expected.clone(),
|
identity: expected.clone(),
|
||||||
disposition: HealObjectDisposition::Repaired,
|
disposition: HealObjectDisposition::Repaired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
receipt.identity.bucket_incarnation_id = None;
|
||||||
assert!(
|
assert!(
|
||||||
!receipt.verified_for(&expected),
|
!receipt.verified_for(&expected),
|
||||||
"a positive storage receipt without bucket incarnation must remain untrusted"
|
"a positive storage receipt without bucket incarnation must remain untrusted"
|
||||||
);
|
);
|
||||||
|
|
||||||
let incarnation = Uuid::new_v4();
|
|
||||||
receipt.identity.bucket_incarnation_id = Some(incarnation);
|
receipt.identity.bucket_incarnation_id = Some(incarnation);
|
||||||
assert!(receipt.verified_for(&expected));
|
assert!(receipt.verified_for(&expected));
|
||||||
|
|
||||||
|
receipt.identity.bucket_incarnation_id = Some(Uuid::new_v4());
|
||||||
|
assert!(
|
||||||
|
!receipt.verified_for(&expected),
|
||||||
|
"a storage receipt for a different bucket incarnation must not clear the requested responsibility"
|
||||||
|
);
|
||||||
|
|
||||||
receipt.identity.version_id = Some("older-version".to_string());
|
receipt.identity.version_id = Some("older-version".to_string());
|
||||||
assert!(
|
assert!(
|
||||||
!receipt.verified_for(&expected),
|
!receipt.verified_for(&expected),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use rustfs_madmin::heal_commands::HealResultItem;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::outcome::{HealObjectDisposition, HealObjectIdentity, HealObjectKind, HealObjectReceipt};
|
use super::outcome::{HealObjectDisposition, HealObjectIdentity, HealObjectKind, HealObjectReceipt};
|
||||||
use super::progress::stable_generation;
|
use super::progress::stable_generation;
|
||||||
@@ -355,6 +356,14 @@ pub trait HealStorageAPI: Send + Sync {
|
|||||||
/// Get bucket info
|
/// Get bucket info
|
||||||
async fn get_bucket_info(&self, bucket: &str) -> Result<Option<BucketInfo>>;
|
async fn get_bucket_info(&self, bucket: &str) -> Result<Option<BucketInfo>>;
|
||||||
|
|
||||||
|
/// Return the current bucket incarnation for exact MRF durable proof
|
||||||
|
/// matching. Alternate backends that cannot expose this must return
|
||||||
|
/// `None`, leaving replay anchors retained instead of acknowledged with an
|
||||||
|
/// incomplete identity.
|
||||||
|
async fn mrf_bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Aggregate usage-cache baselines for the requested buckets.
|
/// Aggregate usage-cache baselines for the requested buckets.
|
||||||
async fn erasure_set_usage_baseline(&self, _buckets: &[String]) -> Result<Option<HealBucketUsageBaseline>> {
|
async fn erasure_set_usage_baseline(&self, _buckets: &[String]) -> Result<Option<HealBucketUsageBaseline>> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -383,6 +392,11 @@ pub trait HealStorageAPI: Send + Sync {
|
|||||||
/// Check object exists
|
/// Check object exists
|
||||||
async fn object_exists(&self, bucket: &str, object: &str) -> Result<bool>;
|
async fn object_exists(&self, bucket: &str, object: &str) -> Result<bool>;
|
||||||
|
|
||||||
|
/// Stable bucket incarnation observed before an object heal starts.
|
||||||
|
async fn bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Heal object using ecstore
|
/// Heal object using ecstore
|
||||||
async fn heal_object(
|
async fn heal_object(
|
||||||
&self,
|
&self,
|
||||||
@@ -809,6 +823,14 @@ impl HealStorageAPI for ECStoreHealStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mrf_bucket_incarnation_id(&self, bucket: &str) -> Result<Option<Uuid>> {
|
||||||
|
self.ecstore
|
||||||
|
.bucket_incarnation_id(bucket)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(Error::Storage)
|
||||||
|
}
|
||||||
|
|
||||||
async fn erasure_set_usage_baseline(&self, buckets: &[String]) -> Result<Option<HealBucketUsageBaseline>> {
|
async fn erasure_set_usage_baseline(&self, buckets: &[String]) -> Result<Option<HealBucketUsageBaseline>> {
|
||||||
if buckets.is_empty() {
|
if buckets.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1028,6 +1050,14 @@ impl HealStorageAPI for ECStoreHealStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn bucket_incarnation_id(&self, bucket: &str) -> Result<Option<Uuid>> {
|
||||||
|
self.ecstore
|
||||||
|
.bucket_incarnation_id(bucket)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(Error::Storage)
|
||||||
|
}
|
||||||
|
|
||||||
async fn heal_object(
|
async fn heal_object(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub(crate) use rustfs_ecstore::api::error::{Error as EcstoreErrorType, StorageEr
|
|||||||
pub(crate) use rustfs_ecstore::api::runtime::local_disk_map_read as ecstore_local_disk_map_read;
|
pub(crate) use rustfs_ecstore::api::runtime::local_disk_map_read as ecstore_local_disk_map_read;
|
||||||
pub(crate) use rustfs_ecstore::api::storage::{
|
pub(crate) use rustfs_ecstore::api::storage::{
|
||||||
ECStore as EcstoreStore, HealLifecycleExpiryContext as EcstoreHealLifecycleExpiryContext,
|
ECStore as EcstoreStore, HealLifecycleExpiryContext as EcstoreHealLifecycleExpiryContext,
|
||||||
|
POOL_META_NAME as ECSTORE_POOL_META_NAME,
|
||||||
};
|
};
|
||||||
use rustfs_storage_api as storage_contracts;
|
use rustfs_storage_api as storage_contracts;
|
||||||
|
|
||||||
@@ -36,10 +37,11 @@ pub(crate) mod owner {
|
|||||||
pub(crate) use super::storage_contracts::{ObjectIO, ObjectOperations};
|
pub(crate) use super::storage_contracts::{ObjectIO, ObjectOperations};
|
||||||
|
|
||||||
pub(crate) use super::{
|
pub(crate) use super::{
|
||||||
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_RUSTFS_META_BUCKET,
|
ECSTORE_BUCKET_META_PREFIX, ECSTORE_DATA_USAGE_CACHE_NAME, ECSTORE_HEALING_MARKER_PATH, ECSTORE_POOL_META_NAME,
|
||||||
EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes, EcstoreDiskError,
|
ECSTORE_RUSTFS_META_BUCKET, EcstoreConditionalFileUpdate, EcstoreDeleteOptions, EcstoreDiskAPI, EcstoreDiskBytes,
|
||||||
EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType, EcstoreHealLifecycleExpiryContext,
|
EcstoreDiskError, EcstoreDiskResult, EcstoreDiskStore, EcstoreEndpoint, EcstoreErrorType,
|
||||||
EcstoreStorageError, EcstoreStore, ecstore_load_admin_data_usage_from_backend_cached, ecstore_local_disk_map_read,
|
EcstoreHealLifecycleExpiryContext, EcstoreStorageError, EcstoreStore, ecstore_load_admin_data_usage_from_backend_cached,
|
||||||
|
ecstore_local_disk_map_read,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) use super::{EcstoreDiskOption, ecstore_new_disk};
|
pub(crate) use super::{EcstoreDiskOption, ecstore_new_disk};
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ use tokio::sync::RwLock;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, RUSTFS_META_BUCKET};
|
use super::{BUCKET_META_PREFIX, DATA_USAGE_CACHE_NAME, POOL_META_NAME, RUSTFS_META_BUCKET};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) struct OutcomeFinishTestHook {
|
pub(crate) struct OutcomeFinishTestHook {
|
||||||
@@ -106,7 +106,7 @@ const EVENT_HEAL_ERASURE_SET_STAGE: &str = "heal_erasure_set_stage";
|
|||||||
const EVENT_HEAL_ERASURE_SET_RESULT: &str = "heal_erasure_set_result";
|
const EVENT_HEAL_ERASURE_SET_RESULT: &str = "heal_erasure_set_result";
|
||||||
|
|
||||||
/// Heal type
|
/// Heal type
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum HealType {
|
pub enum HealType {
|
||||||
/// Cluster heal
|
/// Cluster heal
|
||||||
Cluster,
|
Cluster,
|
||||||
@@ -209,7 +209,7 @@ impl HealPriority {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Heal options
|
/// Heal options
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct HealOptions {
|
pub struct HealOptions {
|
||||||
/// Scan mode
|
/// Scan mode
|
||||||
pub scan_mode: HealScanMode,
|
pub scan_mode: HealScanMode,
|
||||||
@@ -513,6 +513,11 @@ impl HealTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn set_execution_elapsed_for_test(&self, elapsed: Duration) {
|
||||||
|
*self.task_start_instant.write().await = Some(Instant::now() - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn retry_request_with_remaining_timeout(&self) -> Result<HealRequest> {
|
pub(crate) async fn retry_request_with_remaining_timeout(&self) -> Result<HealRequest> {
|
||||||
let mut request = self.retry_request();
|
let mut request = self.retry_request();
|
||||||
if self.options.timeout.is_some() {
|
if self.options.timeout.is_some() {
|
||||||
@@ -574,6 +579,18 @@ impl HealTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) async fn outcome_bucket_incarnation_id(&self, bucket: &str, dry_run: bool) -> Result<Option<Uuid>> {
|
||||||
|
if dry_run {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match self.await_with_control(self.storage.bucket_incarnation_id(bucket)).await {
|
||||||
|
Ok(incarnation_id) => Ok(incarnation_id),
|
||||||
|
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
|
||||||
|
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn single_object_identity(&self) -> Option<HealObjectIdentity> {
|
fn single_object_identity(&self) -> Option<HealObjectIdentity> {
|
||||||
let (bucket, object, version) = match &self.heal_type {
|
let (bucket, object, version) = match &self.heal_type {
|
||||||
HealType::Object {
|
HealType::Object {
|
||||||
@@ -597,6 +614,9 @@ impl HealTask {
|
|||||||
expected: HealObjectIdentity,
|
expected: HealObjectIdentity,
|
||||||
receipt: Option<HealObjectReceipt>,
|
receipt: Option<HealObjectReceipt>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
if self.options.dry_run || self.cancel_token.is_cancelled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let Some(receipt) = receipt else {
|
let Some(receipt) = receipt else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -340,9 +340,88 @@ impl HealTask {
|
|||||||
return Err(self.record_batch_failure(failure).await);
|
return Err(self.record_batch_failure(failure).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.options.recreate_missing && !self.options.dry_run {
|
||||||
|
self.heal_cluster_pool_metadata().await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn heal_cluster_pool_metadata(&self) -> Result<()> {
|
||||||
|
let heal_opts = HealOpts {
|
||||||
|
recursive: false,
|
||||||
|
dry_run: self.options.dry_run,
|
||||||
|
remove: false,
|
||||||
|
recreate: self.options.recreate_missing,
|
||||||
|
scan_mode: self.options.scan_mode,
|
||||||
|
update_parity: self.options.update_parity,
|
||||||
|
no_lock: self.options.no_lock,
|
||||||
|
read_repair: false,
|
||||||
|
pool: self.options.pool_index,
|
||||||
|
set: self.options.set_index,
|
||||||
|
};
|
||||||
|
|
||||||
|
let heal_result = self
|
||||||
|
.await_with_control(self.storage.heal_object(RUSTFS_META_BUCKET, POOL_META_NAME, None, &heal_opts))
|
||||||
|
.await;
|
||||||
|
match heal_result {
|
||||||
|
Ok((result, None)) => {
|
||||||
|
debug!(
|
||||||
|
target: "rustfs::heal::task",
|
||||||
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TASK,
|
||||||
|
task_id = %self.id,
|
||||||
|
bucket = RUSTFS_META_BUCKET,
|
||||||
|
object = POOL_META_NAME,
|
||||||
|
drives_healed = result.drives_healed(),
|
||||||
|
drives_total = result.drives_reported(),
|
||||||
|
result = "pool_metadata_ok",
|
||||||
|
"Heal cluster pool metadata repaired"
|
||||||
|
);
|
||||||
|
self.record_result_item(result).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok((result, Some(err))) => {
|
||||||
|
self.record_result_item(result).await;
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::heal::task",
|
||||||
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TASK,
|
||||||
|
task_id = %self.id,
|
||||||
|
bucket = RUSTFS_META_BUCKET,
|
||||||
|
object = POOL_META_NAME,
|
||||||
|
result = "pool_metadata_failed",
|
||||||
|
error = %err,
|
||||||
|
"Heal cluster pool metadata failed"
|
||||||
|
);
|
||||||
|
Err(Error::TaskExecutionFailed {
|
||||||
|
message: format!("Failed to heal cluster pool metadata: {err}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(Error::TaskCancelled) => Err(Error::TaskCancelled),
|
||||||
|
Err(Error::TaskTimeout) => Err(Error::TaskTimeout),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::heal::task",
|
||||||
|
event = EVENT_HEAL_BUCKET_RESULT,
|
||||||
|
component = LOG_COMPONENT_HEAL,
|
||||||
|
subsystem = LOG_SUBSYSTEM_TASK,
|
||||||
|
task_id = %self.id,
|
||||||
|
bucket = RUSTFS_META_BUCKET,
|
||||||
|
object = POOL_META_NAME,
|
||||||
|
result = "pool_metadata_failed",
|
||||||
|
error = %err,
|
||||||
|
"Heal cluster pool metadata failed"
|
||||||
|
);
|
||||||
|
Err(Error::TaskExecutionFailed {
|
||||||
|
message: format!("Failed to heal cluster pool metadata: {err}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
|
pub(super) async fn heal_prefix(&self, bucket: &str, prefix: &str) -> Result<()> {
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
@@ -403,6 +482,7 @@ impl HealTask {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (set_disk_id, heal_opts) in listing_scopes {
|
for (set_disk_id, heal_opts) in listing_scopes {
|
||||||
|
let bucket_incarnation_id = self.outcome_bucket_incarnation_id(bucket, heal_opts.dry_run).await?;
|
||||||
let mut continuation_token: Option<String> = None;
|
let mut continuation_token: Option<String> = None;
|
||||||
let mut deferred = DeferredWindow::default();
|
let mut deferred = DeferredWindow::default();
|
||||||
let mut inline_retry: Option<DeferredObject> = None;
|
let mut inline_retry: Option<DeferredObject> = None;
|
||||||
@@ -501,13 +581,15 @@ impl HealTask {
|
|||||||
let retry_attempt = item.attempt;
|
let retry_attempt = item.attempt;
|
||||||
let mut telemetry_unknown = false;
|
let mut telemetry_unknown = false;
|
||||||
let object = item.name.as_str();
|
let object = item.name.as_str();
|
||||||
let identity =
|
let mut identity =
|
||||||
self.outcome_identity(bucket, object, item.version_id.as_deref(), heal_opts.pool, heal_opts.set);
|
self.outcome_identity(bucket, object, item.version_id.as_deref(), heal_opts.pool, heal_opts.set);
|
||||||
|
identity.bucket_incarnation_id = bucket_incarnation_id;
|
||||||
let mut disposition = if heal_opts.dry_run {
|
let mut disposition = if heal_opts.dry_run {
|
||||||
HealObjectDisposition::DryRunObserved
|
HealObjectDisposition::DryRunObserved
|
||||||
} else {
|
} else {
|
||||||
HealObjectDisposition::Unknown
|
HealObjectDisposition::Unknown
|
||||||
};
|
};
|
||||||
|
let mut recorded_authoritative_outcome = false;
|
||||||
let mut detail = None;
|
let mut detail = None;
|
||||||
{
|
{
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
@@ -520,23 +602,37 @@ impl HealTask {
|
|||||||
Some(Error::other("heal object retry age exhausted"))
|
Some(Error::other("heal object retry age exhausted"))
|
||||||
} else {
|
} else {
|
||||||
match self
|
match self
|
||||||
.await_with_control(
|
.await_with_control(self.storage.heal_object_with_receipt(
|
||||||
self.storage
|
bucket,
|
||||||
.heal_object(bucket, object, item.version_id.as_deref(), &heal_opts),
|
object,
|
||||||
)
|
item.version_id.as_deref(),
|
||||||
|
&heal_opts,
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((result, None)) => match unavailable_recreate_error(&result, &heal_opts) {
|
Ok(storage_result) if storage_result.error.is_none() => {
|
||||||
|
match unavailable_recreate_error(&storage_result.item, &heal_opts) {
|
||||||
Some(error) => Some(error),
|
Some(error) => Some(error),
|
||||||
None => {
|
None => {
|
||||||
telemetry_unknown |= !increment_counter(&mut healed);
|
telemetry_unknown |= !increment_counter(&mut healed);
|
||||||
telemetry_unknown |=
|
telemetry_unknown |= !add_bytes(
|
||||||
!add_bytes(&mut bytes, u64::try_from(result.object_size).unwrap_or(u64::MAX));
|
&mut bytes,
|
||||||
self.record_result_item(result).await;
|
u64::try_from(storage_result.item.object_size).unwrap_or(u64::MAX),
|
||||||
|
);
|
||||||
|
recorded_authoritative_outcome = self
|
||||||
|
.record_verified_storage_receipt(identity.clone(), storage_result.receipt)
|
||||||
|
.await;
|
||||||
|
self.record_result_item(storage_result.item).await;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Ok((_, Some(err))) if is_missing_object_dir_heal_result(object, &err) => {
|
}
|
||||||
|
Ok(storage_result)
|
||||||
|
if storage_result
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|err| is_missing_object_dir_heal_result(object, err)) =>
|
||||||
|
{
|
||||||
telemetry_unknown |= !increment_counter(&mut healed);
|
telemetry_unknown |= !increment_counter(&mut healed);
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
@@ -551,7 +647,8 @@ impl HealTask {
|
|||||||
);
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Ok((_, Some(err))) | Err(err) => Some(err),
|
Ok(storage_result) => storage_result.error,
|
||||||
|
Err(err) => Some(err),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -674,11 +771,13 @@ impl HealTask {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !recorded_authoritative_outcome {
|
||||||
self.outcome.write().await.record(HealObjectOutcome {
|
self.outcome.write().await.record(HealObjectOutcome {
|
||||||
identity,
|
identity,
|
||||||
disposition,
|
disposition,
|
||||||
detail,
|
detail,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_object_progress(
|
progress.update_object_progress(
|
||||||
|
|||||||
@@ -451,6 +451,11 @@ impl HealTask {
|
|||||||
self.source,
|
self.source,
|
||||||
)
|
)
|
||||||
.with_replacement_targets(replacement_targets, is_auto_replacement.then(|| self.id.clone()))
|
.with_replacement_targets(replacement_targets, is_auto_replacement.then(|| self.id.clone()))
|
||||||
|
.with_pool_metadata_targets(if self.options.recreate_missing && !self.options.dry_run {
|
||||||
|
self.heal_endpoints.clone()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
.with_replacement_identity_fence(replacement_target_identities.clone())
|
.with_replacement_identity_fence(replacement_target_identities.clone())
|
||||||
.with_mainline_pacer(self.mainline_pacer.clone());
|
.with_mainline_pacer(self.mainline_pacer.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ impl HealTask {
|
|||||||
pool: self.options.pool_index,
|
pool: self.options.pool_index,
|
||||||
set: self.options.set_index,
|
set: self.options.set_index,
|
||||||
};
|
};
|
||||||
|
let mut expected_identity =
|
||||||
|
self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
|
||||||
|
expected_identity.bucket_incarnation_id = self.outcome_bucket_incarnation_id(bucket, self.options.dry_run).await?;
|
||||||
|
|
||||||
let heal_fut = self.storage.heal_object_with_receipt(bucket, object, version_id, &heal_opts);
|
let heal_fut = self.storage.heal_object_with_receipt(bucket, object, version_id, &heal_opts);
|
||||||
let heal_result = if self.source == HealRequestSource::ReadRepair {
|
let heal_result = if self.source == HealRequestSource::ReadRepair {
|
||||||
@@ -266,8 +269,6 @@ impl HealTask {
|
|||||||
let mut progress = self.progress.write().await;
|
let mut progress = self.progress.write().await;
|
||||||
progress.update_object_progress(1, 1, 0, 0, object_size);
|
progress.update_object_progress(1, 1, 0, 0, object_size);
|
||||||
}
|
}
|
||||||
let expected_identity =
|
|
||||||
self.outcome_identity(bucket, object, version_id, self.options.pool_index, self.options.set_index);
|
|
||||||
self.record_verified_storage_receipt(expected_identity, storage_result.receipt)
|
self.record_verified_storage_receipt(expected_identity, storage_result.receipt)
|
||||||
.await;
|
.await;
|
||||||
self.record_result_item(result).await;
|
self.record_result_item(result).await;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ mod canonical_outcome {
|
|||||||
assert_eq!(task.get_progress().await.objects_scanned, 2);
|
assert_eq!(task.get_progress().await.objects_scanned, 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||||
["object-a", "object-b"]
|
["object-a", "object-b", POOL_META_NAME]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage.listing_tokens.lock().expect("listing tokens").as_slice(),
|
storage.listing_tokens.lock().expect("listing tokens").as_slice(),
|
||||||
@@ -154,6 +154,126 @@ mod canonical_outcome {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bucket_heal_records_matching_positive_storage_receipts() {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([
|
||||||
|
(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt("object-a", None, HealObjectDisposition::Repaired, incarnation)]),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"object-b".to_string(),
|
||||||
|
VecDeque::from([object_receipt("object-b", None, HealObjectDisposition::Repaired, incarnation)]),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(incarnation)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = bucket_task(storage);
|
||||||
|
|
||||||
|
task.execute()
|
||||||
|
.await
|
||||||
|
.expect("bucket heal should record verified object receipts");
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.execution, HealExecutionOutcome::Completed);
|
||||||
|
assert_eq!(outcome.counters.healed, 2);
|
||||||
|
assert_eq!(outcome.counters.unknown, 0);
|
||||||
|
assert_eq!(outcome.objects.len(), 2);
|
||||||
|
assert!(outcome.objects.iter().all(|item| {
|
||||||
|
item.identity.bucket_incarnation_id == Some(incarnation) && item.disposition == HealObjectDisposition::Repaired
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bucket_heal_keeps_repairing_when_bucket_incarnation_is_unavailable() {
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
None,
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
Uuid::new_v4(),
|
||||||
|
)]),
|
||||||
|
)])),
|
||||||
|
bucket_incarnation_unavailable: Mutex::new(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = bucket_task(storage.clone());
|
||||||
|
|
||||||
|
task.execute()
|
||||||
|
.await
|
||||||
|
.expect("bucket heal should continue when only proof ownership is unavailable");
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.execution, HealExecutionOutcome::Completed);
|
||||||
|
assert_eq!(outcome.counters.healed, 0);
|
||||||
|
assert_eq!(outcome.counters.unknown, 2);
|
||||||
|
assert!(
|
||||||
|
outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.all(|item| item.disposition == HealObjectDisposition::Unknown)
|
||||||
|
);
|
||||||
|
assert_eq!(storage.healed_objects.lock().expect("healed objects").len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bucket_heal_rejects_stale_receipts_without_double_recording() {
|
||||||
|
let expected_incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([
|
||||||
|
(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
None,
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
Uuid::new_v4(),
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"object-b".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-b",
|
||||||
|
None,
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
expected_incarnation,
|
||||||
|
)]),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(expected_incarnation)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = bucket_task(storage);
|
||||||
|
|
||||||
|
task.execute()
|
||||||
|
.await
|
||||||
|
.expect("stale bucket receipt should not fail the legacy heal");
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.execution, HealExecutionOutcome::Completed);
|
||||||
|
assert_eq!(outcome.counters.healed, 1);
|
||||||
|
assert_eq!(outcome.counters.unknown, 1);
|
||||||
|
assert_eq!(outcome.objects.len(), 2);
|
||||||
|
let object_a = outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.identity.object == "object-a")
|
||||||
|
.expect("stale receipt object outcome");
|
||||||
|
assert_eq!(object_a.disposition, HealObjectDisposition::Unknown);
|
||||||
|
let object_b = outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.identity.object == "object-b")
|
||||||
|
.expect("matching receipt object outcome");
|
||||||
|
assert_eq!(object_b.disposition, HealObjectDisposition::Repaired);
|
||||||
|
assert_eq!(object_b.identity.bucket_incarnation_id, Some(expected_incarnation));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
async fn exhausted_object_does_not_abort_other_objects_or_erase_counts() {
|
async fn exhausted_object_does_not_abort_other_objects_or_erase_counts() {
|
||||||
let storage = Arc::new(MockStorage::default());
|
let storage = Arc::new(MockStorage::default());
|
||||||
@@ -313,6 +433,96 @@ mod canonical_outcome {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mixed_grace_and_repaired_receipt_transfer_only_repaired_responsibility() {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_outcomes: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([MockHealObjectOutcome::DanglingGraceDeferred]),
|
||||||
|
)])),
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-b".to_string(),
|
||||||
|
VecDeque::from([object_receipt("object-b", None, HealObjectDisposition::Repaired, incarnation)]),
|
||||||
|
)])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(incarnation)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = bucket_task(storage);
|
||||||
|
|
||||||
|
task.execute()
|
||||||
|
.await
|
||||||
|
.expect("mixed grace and repaired receipt should complete");
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.coverage, HealTraversalCoverage::Complete);
|
||||||
|
assert_eq!(outcome.counters.processed, 2);
|
||||||
|
assert_eq!(outcome.counters.healed, 1);
|
||||||
|
assert_eq!(outcome.counters.skipped, 1);
|
||||||
|
let deferred = outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.identity.object == "object-a")
|
||||||
|
.expect("grace object should remain recorded");
|
||||||
|
assert!(matches!(
|
||||||
|
deferred.disposition,
|
||||||
|
HealObjectDisposition::Deferred {
|
||||||
|
reason: HealDeferredReason::DanglingDeleteGrace,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert_ne!(deferred.disposition, HealObjectDisposition::Repaired);
|
||||||
|
let repaired = outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.identity.object == "object-b")
|
||||||
|
.expect("receipt-backed object should be recorded");
|
||||||
|
assert_eq!(repaired.identity.bucket_incarnation_id, Some(incarnation));
|
||||||
|
assert_eq!(repaired.disposition, HealObjectDisposition::Repaired);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bucket_heal_records_matching_positive_storage_receipt() {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt("object-a", None, HealObjectDisposition::Repaired, incarnation)]),
|
||||||
|
)])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(incarnation)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = bucket_task(storage);
|
||||||
|
|
||||||
|
task.execute().await.expect("bucket heal should complete");
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.counters.processed, 2);
|
||||||
|
assert_eq!(outcome.counters.healed, 1);
|
||||||
|
assert_eq!(outcome.counters.unknown, 1);
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.identity.object == "object-a")
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let repaired = outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.identity.object == "object-a")
|
||||||
|
.expect("receipt-backed bucket object should be recorded");
|
||||||
|
assert_eq!(repaired.identity.bucket_incarnation_id, Some(incarnation));
|
||||||
|
assert_eq!(repaired.disposition, HealObjectDisposition::Repaired);
|
||||||
|
let legacy = outcome
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.identity.object == "object-b")
|
||||||
|
.expect("legacy bucket object should still be recorded");
|
||||||
|
assert_eq!(legacy.disposition, HealObjectDisposition::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn grace_single_object_is_completed_but_deferred() {
|
async fn grace_single_object_is_completed_but_deferred() {
|
||||||
let storage = Arc::new(MockStorage {
|
let storage = Arc::new(MockStorage {
|
||||||
@@ -344,6 +554,18 @@ mod canonical_outcome {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("existence fixture")
|
.expect("existence fixture")
|
||||||
.insert("object".to_string(), MockObjectExists::TransientSkip("retry later"));
|
.insert("object".to_string(), MockObjectExists::TransientSkip("retry later"));
|
||||||
|
} else {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
*storage.bucket_incarnation_id.lock().expect("bucket incarnation") = Some(incarnation);
|
||||||
|
storage.heal_object_receipts.lock().expect("receipt fixture").insert(
|
||||||
|
"object".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object",
|
||||||
|
None,
|
||||||
|
HealObjectDisposition::VerifiedHealthy,
|
||||||
|
incarnation,
|
||||||
|
)]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut request = HealRequest::object("bucket-a".to_string(), "object".to_string(), None);
|
let mut request = HealRequest::object("bucket-a".to_string(), "object".to_string(), None);
|
||||||
request.options.dry_run = !transient;
|
request.options.dry_run = !transient;
|
||||||
@@ -1116,6 +1338,9 @@ struct MockStorage {
|
|||||||
heal_object_outcome: Mutex<Option<MockHealObjectOutcome>>,
|
heal_object_outcome: Mutex<Option<MockHealObjectOutcome>>,
|
||||||
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
heal_object_outcomes: Mutex<HashMap<String, VecDeque<MockHealObjectOutcome>>>,
|
||||||
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
|
heal_object_receipts: Mutex<HashMap<String, VecDeque<HealObjectReceipt>>>,
|
||||||
|
bucket_incarnation_id: Mutex<Option<Uuid>>,
|
||||||
|
bucket_incarnation_after_object_heal: Mutex<Option<Uuid>>,
|
||||||
|
bucket_incarnation_unavailable: Mutex<bool>,
|
||||||
format_no_heal_required: Mutex<bool>,
|
format_no_heal_required: Mutex<bool>,
|
||||||
format_error: Mutex<Option<Error>>,
|
format_error: Mutex<Option<Error>>,
|
||||||
global_format_calls: Mutex<u32>,
|
global_format_calls: Mutex<u32>,
|
||||||
@@ -1219,14 +1444,19 @@ async fn execute_emits_heal_trace_task_state() {
|
|||||||
assert_eq!(trace_attr_string(&completed, "state").as_deref(), Some("completed"));
|
assert_eq!(trace_attr_string(&completed, "state").as_deref(), Some("completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn object_receipt(object: &str, version_id: Option<&str>, disposition: HealObjectDisposition) -> HealObjectReceipt {
|
fn object_receipt(
|
||||||
|
object: &str,
|
||||||
|
version_id: Option<&str>,
|
||||||
|
disposition: HealObjectDisposition,
|
||||||
|
bucket_incarnation_id: Uuid,
|
||||||
|
) -> HealObjectReceipt {
|
||||||
HealObjectReceipt {
|
HealObjectReceipt {
|
||||||
identity: HealObjectIdentity {
|
identity: HealObjectIdentity {
|
||||||
kind: HealObjectKind::Object,
|
kind: HealObjectKind::Object,
|
||||||
bucket: "bucket-a".to_string(),
|
bucket: "bucket-a".to_string(),
|
||||||
object: object.to_string(),
|
object: object.to_string(),
|
||||||
version_id: version_id.map(ToOwned::to_owned),
|
version_id: version_id.map(ToOwned::to_owned),
|
||||||
bucket_incarnation_id: Some(Uuid::new_v4()),
|
bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||||
pool_index: None,
|
pool_index: None,
|
||||||
set_index: None,
|
set_index: None,
|
||||||
},
|
},
|
||||||
@@ -1236,11 +1466,18 @@ fn object_receipt(object: &str, version_id: Option<&str>, disposition: HealObjec
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn object_heal_records_matching_positive_storage_receipt() {
|
async fn object_heal_records_matching_positive_storage_receipt() {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
let storage = Arc::new(MockStorage {
|
let storage = Arc::new(MockStorage {
|
||||||
heal_object_receipts: Mutex::new(HashMap::from([(
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
"object-a".to_string(),
|
"object-a".to_string(),
|
||||||
VecDeque::from([object_receipt("object-a", Some("version-a"), HealObjectDisposition::Repaired)]),
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
Some("version-a"),
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
incarnation,
|
||||||
|
)]),
|
||||||
)])),
|
)])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(incarnation)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let task = HealTask::from_request(
|
let task = HealTask::from_request(
|
||||||
@@ -1261,16 +1498,165 @@ async fn object_heal_records_matching_positive_storage_receipt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn object_heal_rejects_mismatched_or_legacy_storage_receipts() {
|
async fn cancelled_object_heal_rejects_matching_positive_storage_receipt() {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage::default());
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
task.cancel().await.expect("task cancellation should succeed");
|
||||||
|
|
||||||
|
let expected = HealObjectIdentity {
|
||||||
|
kind: HealObjectKind::Object,
|
||||||
|
bucket: "bucket-a".to_string(),
|
||||||
|
object: "object-a".to_string(),
|
||||||
|
version_id: Some("version-a".to_string()),
|
||||||
|
bucket_incarnation_id: Some(incarnation),
|
||||||
|
pool_index: None,
|
||||||
|
set_index: None,
|
||||||
|
};
|
||||||
|
let accepted = task
|
||||||
|
.record_verified_storage_receipt(
|
||||||
|
expected,
|
||||||
|
Some(object_receipt(
|
||||||
|
"object-a",
|
||||||
|
Some("version-a"),
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
incarnation,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert!(!accepted);
|
||||||
|
assert_eq!(outcome.counters.healed, 0);
|
||||||
|
assert!(outcome.objects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_object_heal_rejects_matching_positive_storage_receipt() {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_outcome: Mutex::new(Some(MockHealObjectOutcome::OkWithOtherError("commit failed"))),
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
Some("version-a"),
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
incarnation,
|
||||||
|
)]),
|
||||||
|
)])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(incarnation)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = task.execute().await;
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(outcome.counters.healed, 0);
|
||||||
|
assert_eq!(outcome.counters.unchanged, 0);
|
||||||
|
assert!(outcome.objects.iter().all(|object| {
|
||||||
|
!matches!(
|
||||||
|
object.disposition,
|
||||||
|
HealObjectDisposition::Repaired
|
||||||
|
| HealObjectDisposition::VerifiedHealthy
|
||||||
|
| HealObjectDisposition::AuthoritativelyAbsent
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transient_quorum_object_heal_rejects_matching_positive_storage_receipt() {
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_outcome: Mutex::new(Some(MockHealObjectOutcome::OkWithReadQuorum)),
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
Some("version-a"),
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
incarnation,
|
||||||
|
)]),
|
||||||
|
)])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(incarnation)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = task.execute().await;
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(outcome.counters.healed, 0);
|
||||||
|
assert_eq!(outcome.counters.unchanged, 0);
|
||||||
|
assert!(outcome.objects.iter().all(|object| {
|
||||||
|
!matches!(
|
||||||
|
object.disposition,
|
||||||
|
HealObjectDisposition::Repaired
|
||||||
|
| HealObjectDisposition::VerifiedHealthy
|
||||||
|
| HealObjectDisposition::AuthoritativelyAbsent
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_heal_latches_expected_incarnation_before_repair() {
|
||||||
|
let original_incarnation = Uuid::new_v4();
|
||||||
|
let successor_incarnation = Uuid::new_v4();
|
||||||
let storage = Arc::new(MockStorage {
|
let storage = Arc::new(MockStorage {
|
||||||
heal_object_receipts: Mutex::new(HashMap::from([(
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
"object-a".to_string(),
|
"object-a".to_string(),
|
||||||
VecDeque::from([object_receipt(
|
VecDeque::from([object_receipt(
|
||||||
"object-a",
|
"object-a",
|
||||||
Some("old-version"),
|
Some("version-a"),
|
||||||
HealObjectDisposition::Repaired,
|
HealObjectDisposition::VerifiedHealthy,
|
||||||
|
original_incarnation,
|
||||||
)]),
|
)]),
|
||||||
)])),
|
)])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(original_incarnation)),
|
||||||
|
bucket_incarnation_after_object_heal: Mutex::new(Some(successor_incarnation)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let task = HealTask::from_request(
|
||||||
|
HealRequest::object("bucket-a".to_string(), "object-a".to_string(), Some("version-a".to_string())),
|
||||||
|
storage,
|
||||||
|
);
|
||||||
|
|
||||||
|
task.execute().await.expect("mock object heal should complete");
|
||||||
|
|
||||||
|
let outcome = task.get_outcome().await;
|
||||||
|
assert_eq!(outcome.counters.unchanged, 1);
|
||||||
|
assert_eq!(outcome.counters.unknown, 0);
|
||||||
|
let object = outcome.objects.front().expect("latched receipt should be recorded");
|
||||||
|
assert_eq!(object.identity.bucket_incarnation_id, Some(original_incarnation));
|
||||||
|
assert_eq!(object.disposition, HealObjectDisposition::VerifiedHealthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn object_heal_rejects_mismatched_or_legacy_storage_receipts() {
|
||||||
|
let expected_incarnation = Uuid::new_v4();
|
||||||
|
let storage = Arc::new(MockStorage {
|
||||||
|
heal_object_receipts: Mutex::new(HashMap::from([(
|
||||||
|
"object-a".to_string(),
|
||||||
|
VecDeque::from([object_receipt(
|
||||||
|
"object-a",
|
||||||
|
Some("version-a"),
|
||||||
|
HealObjectDisposition::Repaired,
|
||||||
|
Uuid::new_v4(),
|
||||||
|
)]),
|
||||||
|
)])),
|
||||||
|
bucket_incarnation_id: Mutex::new(Some(expected_incarnation)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let task = HealTask::from_request(
|
let task = HealTask::from_request(
|
||||||
@@ -1361,6 +1747,7 @@ enum MockHealObjectOutcome {
|
|||||||
RetryableLock,
|
RetryableLock,
|
||||||
RetryableLockTimeout,
|
RetryableLockTimeout,
|
||||||
OkWithOtherError(&'static str),
|
OkWithOtherError(&'static str),
|
||||||
|
OkWithReadQuorum,
|
||||||
ErrOther(&'static str),
|
ErrOther(&'static str),
|
||||||
DanglingGraceDeferred,
|
DanglingGraceDeferred,
|
||||||
UnavailableDrive(DriveState),
|
UnavailableDrive(DriveState),
|
||||||
@@ -1456,6 +1843,13 @@ impl HealStorageAPI for MockStorage {
|
|||||||
Ok(self.object_exists.lock().unwrap().unwrap_or(true))
|
Ok(self.object_exists.lock().unwrap().unwrap_or(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn bucket_incarnation_id(&self, _bucket: &str) -> Result<Option<Uuid>> {
|
||||||
|
if *self.bucket_incarnation_unavailable.lock().unwrap() {
|
||||||
|
return Err(Error::Other("bucket incarnation unavailable".to_string()));
|
||||||
|
}
|
||||||
|
Ok(*self.bucket_incarnation_id.lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
async fn heal_object(
|
async fn heal_object(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -1513,6 +1907,13 @@ impl HealStorageAPI for MockStorage {
|
|||||||
MockHealObjectOutcome::RetryableSlowDown => {
|
MockHealObjectOutcome::RetryableSlowDown => {
|
||||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::SlowDown))))
|
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::SlowDown))))
|
||||||
}
|
}
|
||||||
|
MockHealObjectOutcome::OkWithReadQuorum => Ok((
|
||||||
|
HealResultItem::default(),
|
||||||
|
Some(Error::Storage(EcstoreError::InsufficientReadQuorum(
|
||||||
|
bucket.to_string(),
|
||||||
|
object.to_string(),
|
||||||
|
))),
|
||||||
|
)),
|
||||||
MockHealObjectOutcome::PermanentOther(message) => Err(Error::other(message)),
|
MockHealObjectOutcome::PermanentOther(message) => Err(Error::other(message)),
|
||||||
MockHealObjectOutcome::OkWithOtherError(message) => Ok((HealResultItem::default(), Some(Error::other(message)))),
|
MockHealObjectOutcome::OkWithOtherError(message) => Ok((HealResultItem::default(), Some(Error::other(message)))),
|
||||||
MockHealObjectOutcome::ErrOther(message) => Err(Error::other(message)),
|
MockHealObjectOutcome::ErrOther(message) => Err(Error::other(message)),
|
||||||
@@ -1552,6 +1953,13 @@ impl HealStorageAPI for MockStorage {
|
|||||||
MockHealObjectOutcome::RetryableSlowDown => {
|
MockHealObjectOutcome::RetryableSlowDown => {
|
||||||
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::SlowDown))))
|
Ok((HealResultItem::default(), Some(Error::Storage(EcstoreError::SlowDown))))
|
||||||
}
|
}
|
||||||
|
MockHealObjectOutcome::OkWithReadQuorum => Ok((
|
||||||
|
HealResultItem::default(),
|
||||||
|
Some(Error::Storage(EcstoreError::InsufficientReadQuorum(
|
||||||
|
bucket.to_string(),
|
||||||
|
object.to_string(),
|
||||||
|
))),
|
||||||
|
)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if bucket == RUSTFS_META_BUCKET && object == format!("{BUCKET_META_PREFIX}/{DATA_USAGE_CACHE_NAME}") {
|
if bucket == RUSTFS_META_BUCKET && object == format!("{BUCKET_META_PREFIX}/{DATA_USAGE_CACHE_NAME}") {
|
||||||
@@ -1566,6 +1974,9 @@ impl HealStorageAPI for MockStorage {
|
|||||||
return Ok((HealResultItem::default(), Some(Error::Disk(DiskError::FileNotFound))));
|
return Ok((HealResultItem::default(), Some(Error::Disk(DiskError::FileNotFound))));
|
||||||
}
|
}
|
||||||
self.healed_objects.lock().unwrap().push(object.to_string());
|
self.healed_objects.lock().unwrap().push(object.to_string());
|
||||||
|
if let Some(bucket_incarnation_id) = self.bucket_incarnation_after_object_heal.lock().unwrap().take() {
|
||||||
|
*self.bucket_incarnation_id.lock().unwrap() = Some(bucket_incarnation_id);
|
||||||
|
}
|
||||||
Ok((
|
Ok((
|
||||||
HealResultItem {
|
HealResultItem {
|
||||||
object_size: 1,
|
object_size: 1,
|
||||||
@@ -2171,11 +2582,95 @@ async fn test_cluster_heal_visits_bucket_objects() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
storage.healed_objects.lock().unwrap().as_slice(),
|
storage.healed_objects.lock().unwrap().as_slice(),
|
||||||
["object-a".to_string(), "object-b".to_string()]
|
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
|
||||||
);
|
);
|
||||||
assert!(matches!(task.get_status().await, HealTaskStatus::Completed));
|
assert!(matches!(task.get_status().await, HealTaskStatus::Completed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cluster_recreate_heals_pool_metadata_after_user_buckets() {
|
||||||
|
let storage = Arc::new(MockStorage::default());
|
||||||
|
let request = HealRequest::new(
|
||||||
|
HealType::Cluster,
|
||||||
|
HealOptions {
|
||||||
|
recursive: true,
|
||||||
|
recreate_missing: true,
|
||||||
|
timeout: None,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealPriority::Normal,
|
||||||
|
);
|
||||||
|
let task = HealTask::from_request(request, storage.clone());
|
||||||
|
|
||||||
|
task.execute()
|
||||||
|
.await
|
||||||
|
.expect("cluster recreate heal should include pool metadata");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||||
|
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
|
||||||
|
);
|
||||||
|
let opts = storage.object_heal_opts.lock().expect("object opts");
|
||||||
|
assert!(opts.last().expect("pool metadata opts").recreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cluster_recreate_fails_when_pool_metadata_heal_fails() {
|
||||||
|
let storage = Arc::new(MockStorage::default());
|
||||||
|
storage.heal_object_outcomes.lock().expect("object outcomes").insert(
|
||||||
|
POOL_META_NAME.to_string(),
|
||||||
|
VecDeque::from([MockHealObjectOutcome::ErrOther("pool metadata missing")]),
|
||||||
|
);
|
||||||
|
let request = HealRequest::new(
|
||||||
|
HealType::Cluster,
|
||||||
|
HealOptions {
|
||||||
|
recursive: true,
|
||||||
|
recreate_missing: true,
|
||||||
|
timeout: None,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealPriority::Normal,
|
||||||
|
);
|
||||||
|
let task = HealTask::from_request(request, storage.clone());
|
||||||
|
|
||||||
|
let err = task
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.expect_err("cluster recreate heal must not hide pool metadata failure");
|
||||||
|
|
||||||
|
assert!(matches!(err, Error::TaskExecutionFailed { .. }));
|
||||||
|
assert_eq!(
|
||||||
|
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||||
|
["object-a".to_string(), "object-b".to_string(), POOL_META_NAME.to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cluster_dry_run_does_not_heal_pool_metadata() {
|
||||||
|
let storage = Arc::new(MockStorage::default());
|
||||||
|
let request = HealRequest::new(
|
||||||
|
HealType::Cluster,
|
||||||
|
HealOptions {
|
||||||
|
recursive: true,
|
||||||
|
dry_run: true,
|
||||||
|
recreate_missing: true,
|
||||||
|
timeout: None,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
HealPriority::Normal,
|
||||||
|
);
|
||||||
|
let task = HealTask::from_request(request, storage.clone());
|
||||||
|
|
||||||
|
task.execute()
|
||||||
|
.await
|
||||||
|
.expect("dry-run cluster heal should preserve existing coverage");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
storage.heal_object_calls.lock().expect("object calls").as_slice(),
|
||||||
|
["object-a".to_string(), "object-b".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn object_heal_skips_dangling_delete_grace_without_failing_task() {
|
async fn object_heal_skips_dangling_delete_grace_without_failing_task() {
|
||||||
let storage = Arc::new(MockStorage {
|
let storage = Arc::new(MockStorage {
|
||||||
|
|||||||
@@ -61,10 +61,14 @@ pub fn create_ahm_services_cancel_token() -> CancellationToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shutdown all heal services gracefully
|
/// Shutdown all heal services gracefully
|
||||||
pub fn shutdown_ahm_services() {
|
pub async fn shutdown_ahm_services() -> Result<()> {
|
||||||
|
if let Some(manager) = get_heal_manager() {
|
||||||
|
manager.stop().await?;
|
||||||
|
}
|
||||||
if let Some(cancel_token) = GLOBAL_AHM_SERVICES_CANCEL_TOKEN.get() {
|
if let Some(cancel_token) = GLOBAL_AHM_SERVICES_CANCEL_TOKEN.get() {
|
||||||
cancel_token.cancel();
|
cancel_token.cancel();
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HealRuntime {
|
struct HealRuntime {
|
||||||
|
|||||||
@@ -22,13 +22,14 @@
|
|||||||
//! Under `cargo nextest` each test runs in its own process, which keeps the
|
//! Under `cargo nextest` each test runs in its own process, which keeps the
|
||||||
//! process-global MRF channel singleton safe.
|
//! process-global MRF channel singleton safe.
|
||||||
|
|
||||||
use rustfs_common::mrf_channel::{self, MrfKind};
|
use rustfs_common::mrf_channel::{self, MrfIngressResult, MrfKind, MrfScope};
|
||||||
use rustfs_heal::heal::{
|
use rustfs_heal::heal::{
|
||||||
manager::{HealConfig, HealManager},
|
manager::{HealConfig, HealManager},
|
||||||
mrf_queue,
|
mrf_queue,
|
||||||
storage::{ECStoreHealStorage, HealStorageAPI},
|
storage::{ECStoreHealStorage, HealStorageAPI},
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::{
|
use std::{
|
||||||
fs::{File, OpenOptions},
|
fs::{File, OpenOptions},
|
||||||
@@ -48,6 +49,12 @@ use storage_api::endpoint_index::{Endpoint, EndpointServerPools, Endpoints, Pool
|
|||||||
const META_BUCKET: &str = ".rustfs.sys";
|
const META_BUCKET: &str = ".rustfs.sys";
|
||||||
const JOURNAL_REL: &str = "buckets/.heal/mrf/journal.bin";
|
const JOURNAL_REL: &str = "buckets/.heal/mrf/journal.bin";
|
||||||
const SCOPED_JOURNAL_REL: &str = "buckets/.heal/mrf/journal-scoped.bin";
|
const SCOPED_JOURNAL_REL: &str = "buckets/.heal/mrf/journal-scoped.bin";
|
||||||
|
const COMMITTED_PAYLOAD_REL: &str = ".heal-mrf-snapshot.0.bin";
|
||||||
|
const COMMITTED_MANIFEST_REL: &str = ".heal-mrf-commit.0.bin";
|
||||||
|
const COMMITTED_PAYLOAD_RELS: [&str; 2] = [".heal-mrf-snapshot.0.bin", ".heal-mrf-snapshot.1.bin"];
|
||||||
|
const COMMITTED_MANIFEST_RELS: [&str; 2] = [".heal-mrf-commit.0.bin", ".heal-mrf-commit.1.bin"];
|
||||||
|
const COMMITTED_MAGIC: &[u8; 8] = b"RFMRFC01";
|
||||||
|
const COMMITTED_MANIFEST_LEN: usize = 8 + 1 + 16 + 8 + 8 + 32 + 32;
|
||||||
|
|
||||||
async fn heal_env() -> (Vec<std::path::PathBuf>, Arc<dyn HealStorageAPI>) {
|
async fn heal_env() -> (Vec<std::path::PathBuf>, Arc<dyn HealStorageAPI>) {
|
||||||
heal_env_at(None).await
|
heal_env_at(None).await
|
||||||
@@ -195,6 +202,29 @@ fn write_journal_to_disks(disk_paths: &[std::path::PathBuf], data: &[u8]) {
|
|||||||
write_journal_path_to_disks(disk_paths, JOURNAL_REL, data);
|
write_journal_path_to_disks(disk_paths, JOURNAL_REL, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn committed_manifest(owner: uuid::Uuid, sequence: u64, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut manifest = Vec::with_capacity(COMMITTED_MANIFEST_LEN);
|
||||||
|
manifest.extend_from_slice(COMMITTED_MAGIC);
|
||||||
|
manifest.push(1);
|
||||||
|
manifest.extend_from_slice(owner.as_bytes());
|
||||||
|
manifest.extend_from_slice(&sequence.to_le_bytes());
|
||||||
|
manifest.extend_from_slice(
|
||||||
|
&u64::try_from(payload.len())
|
||||||
|
.expect("fixture payload length fits")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
manifest.extend_from_slice(&Sha256::digest(payload));
|
||||||
|
manifest.extend_from_slice(&Sha256::digest(&manifest));
|
||||||
|
assert_eq!(manifest.len(), COMMITTED_MANIFEST_LEN, "committed fixture manifest length");
|
||||||
|
manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_committed_snapshot_to_disks(disk_paths: &[std::path::PathBuf], sequence: u64, payload: &[u8]) {
|
||||||
|
let manifest = committed_manifest(uuid::Uuid::new_v4(), sequence, payload);
|
||||||
|
write_journal_path_to_disks(disk_paths, COMMITTED_PAYLOAD_REL, payload);
|
||||||
|
write_journal_path_to_disks(disk_paths, COMMITTED_MANIFEST_REL, &manifest);
|
||||||
|
}
|
||||||
|
|
||||||
fn journal_exists_on_all_disks(disk_paths: &[std::path::PathBuf], relative_path: &str) -> bool {
|
fn journal_exists_on_all_disks(disk_paths: &[std::path::PathBuf], relative_path: &str) -> bool {
|
||||||
disk_paths
|
disk_paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -207,6 +237,71 @@ fn journal_matches_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, exp
|
|||||||
.all(|path| std::fs::read(path.join(META_BUCKET).join(relative_path)).is_ok_and(|actual| actual == expected))
|
.all(|path| std::fs::read(path.join(META_BUCKET).join(relative_path)).is_ok_and(|actual| actual == expected))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn journal_contains_on_all_disks(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
|
||||||
|
disk_paths.iter().all(|path| {
|
||||||
|
std::fs::read(path.join(META_BUCKET).join(relative_path))
|
||||||
|
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn journal_contains_on_any_disk(disk_paths: &[PathBuf], relative_path: &str, needle: &[u8]) -> bool {
|
||||||
|
disk_paths.iter().any(|path| {
|
||||||
|
std::fs::read(path.join(META_BUCKET).join(relative_path))
|
||||||
|
.is_ok_and(|actual| actual.windows(needle.len()).any(|window| window == needle))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_payload_contains_on_all_disks(disk_paths: &[PathBuf], needles: &[&[u8]]) -> bool {
|
||||||
|
disk_paths.iter().all(|path| {
|
||||||
|
let root = path.join(META_BUCKET);
|
||||||
|
COMMITTED_PAYLOAD_RELS.into_iter().any(|payload_rel| {
|
||||||
|
std::fs::read(root.join(payload_rel)).is_ok_and(|payload| {
|
||||||
|
needles
|
||||||
|
.iter()
|
||||||
|
.all(|needle| payload.windows(needle.len()).any(|window| window == *needle))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn committed_checkpoint_matches_on_all_disks(disk_paths: &[PathBuf], sequence: u64, expected_payload: &[u8]) -> bool {
|
||||||
|
disk_paths.iter().all(|path| {
|
||||||
|
let root = path.join(META_BUCKET);
|
||||||
|
COMMITTED_PAYLOAD_RELS
|
||||||
|
.into_iter()
|
||||||
|
.zip(COMMITTED_MANIFEST_RELS)
|
||||||
|
.any(|(payload_rel, manifest_rel)| {
|
||||||
|
let Ok(payload) = std::fs::read(root.join(payload_rel)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if payload != expected_payload {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(manifest) = std::fs::read(root.join(manifest_rel)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if manifest.len() != COMMITTED_MANIFEST_LEN || &manifest[..8] != COMMITTED_MAGIC || manifest[8] != 1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(recorded_sequence) = <[u8; 8]>::try_from(&manifest[25..33]).map(u64::from_le_bytes) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(recorded_len) = <[u8; 8]>::try_from(&manifest[33..41]).map(u64::from_le_bytes) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(expected_len) = u64::try_from(expected_payload.len()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if recorded_sequence != sequence || recorded_len != expected_len {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let payload_digest: [u8; 32] = Sha256::digest(expected_payload).into();
|
||||||
|
let manifest_digest: [u8; 32] = Sha256::digest(&manifest[..COMMITTED_MANIFEST_LEN - 32]).into();
|
||||||
|
payload_digest.as_slice() == &manifest[41..73] && manifest_digest.as_slice() == &manifest[73..]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_until<F, Fut>(deadline: Duration, mut probe: F) -> bool
|
async fn wait_until<F, Fut>(deadline: Duration, mut probe: F) -> bool
|
||||||
where
|
where
|
||||||
F: FnMut() -> Fut,
|
F: FnMut() -> Fut,
|
||||||
@@ -255,11 +350,12 @@ async fn decode_failure_intent_maps_to_urgent_mrf_heal_request() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A journal left behind by a previous process must be replayed into the
|
/// A journal left behind by a previous process must be replayed into the
|
||||||
/// manager queue and then removed, and a torn tail must not block replay of
|
/// manager queue, and a torn tail must not block replay of the intact records.
|
||||||
/// the intact records.
|
/// The partial-write record keeps the legacy journal as the durable anchor
|
||||||
|
/// until an exact verified repair proof can discharge it.
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn journal_replay_arms_intents_and_deletes_the_file() {
|
async fn journal_replay_arms_intents_and_retains_unproven_partial_write_anchor() {
|
||||||
let (disk_paths, storage) = heal_env().await;
|
let (disk_paths, storage) = heal_env().await;
|
||||||
|
|
||||||
// The journal reader resolves disks through the process-local disk map;
|
// The journal reader resolves disks through the process-local disk map;
|
||||||
@@ -285,14 +381,14 @@ async fn journal_replay_arms_intents_and_deletes_the_file() {
|
|||||||
assert!(
|
assert!(
|
||||||
disk_paths
|
disk_paths
|
||||||
.iter()
|
.iter()
|
||||||
.all(|path| !Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()),
|
.all(|path| Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()),
|
||||||
"the journal file must be removed after a successful replay"
|
"partial-write replay must retain the legacy journal until durable proof"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths
|
disk_paths
|
||||||
.iter()
|
.iter()
|
||||||
.all(|path| !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()),
|
.all(|path| !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()),
|
||||||
"the authoritative journal file must also be removed after replay"
|
"missing authoritative journal remains absent"
|
||||||
);
|
);
|
||||||
|
|
||||||
let snapshot = manager.operations_snapshot().await;
|
let snapshot = manager.operations_snapshot().await;
|
||||||
@@ -300,6 +396,76 @@ async fn journal_replay_arms_intents_and_deletes_the_file() {
|
|||||||
assert!(snapshot.queued_by_priority.normal >= 1, "the partial-write record must replay as Normal");
|
assert!(snapshot.queued_by_priority.normal >= 1, "the partial-write record must replay as Normal");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A committed checkpoint published by the new two-slot writer is the
|
||||||
|
/// authoritative startup snapshot. Legacy mirrors are fallback-only and must
|
||||||
|
/// not be merged with or preferred over the committed epoch.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[serial]
|
||||||
|
async fn committed_snapshot_replay_takes_precedence_over_stale_legacy_mirror() {
|
||||||
|
let (disk_paths, storage) = heal_env().await;
|
||||||
|
register_local_disks(&disk_paths, "mrf-committed-replay-test").await;
|
||||||
|
|
||||||
|
let committed = scoped_journal_record(3, "committed-bucket", "committed-object", Some([9u8; 16]), 0, 0, 0);
|
||||||
|
let stale_legacy = journal_record(1, "legacy-bucket", "legacy-object", None, 0);
|
||||||
|
write_committed_snapshot_to_disks(&disk_paths, 7, &committed);
|
||||||
|
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &stale_legacy);
|
||||||
|
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &stale_legacy);
|
||||||
|
|
||||||
|
let manager = make_manager(storage);
|
||||||
|
let replayed = mrf_queue::replay_journal_once(&manager).await;
|
||||||
|
assert_eq!(replayed, 1, "only the committed snapshot epoch may replay");
|
||||||
|
|
||||||
|
let snapshot = manager.operations_snapshot().await;
|
||||||
|
assert_eq!(snapshot.queued_by_source.mrf, 1);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.queued_by_priority.normal, 1,
|
||||||
|
"the committed partial-write record must replay instead of the stale legacy decode-failure"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.queued_by_priority.urgent, 0,
|
||||||
|
"stale legacy decode-failure records must not be mixed into committed replay"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
journal_exists_on_all_disks(&disk_paths, COMMITTED_MANIFEST_REL),
|
||||||
|
"the committed checkpoint remains until the accepted partial-write has proof"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A damaged committed checkpoint is ambiguous: replay must not fall back to
|
||||||
|
/// older legacy bytes or delete any recovery anchor until another process can
|
||||||
|
/// publish a valid successor.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[serial]
|
||||||
|
async fn damaged_committed_snapshot_blocks_legacy_fallback_and_retains_anchors() {
|
||||||
|
let (disk_paths, storage) = heal_env().await;
|
||||||
|
register_local_disks(&disk_paths, "mrf-damaged-committed-replay-test").await;
|
||||||
|
|
||||||
|
let committed = scoped_journal_record(3, "damaged-committed-bucket", "committed-object", Some([8u8; 16]), 0, 0, 0);
|
||||||
|
let stale_legacy = journal_record(1, "damaged-legacy-bucket", "legacy-object", None, 0);
|
||||||
|
write_journal_path_to_disks(&disk_paths, COMMITTED_PAYLOAD_REL, &committed);
|
||||||
|
let mut manifest = committed_manifest(uuid::Uuid::new_v4(), 9, &committed);
|
||||||
|
manifest[25] ^= 1;
|
||||||
|
write_journal_path_to_disks(&disk_paths, COMMITTED_MANIFEST_REL, &manifest);
|
||||||
|
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &stale_legacy);
|
||||||
|
write_journal_path_to_disks(&disk_paths, JOURNAL_REL, &stale_legacy);
|
||||||
|
|
||||||
|
let manager = make_manager(storage);
|
||||||
|
let replayed = mrf_queue::replay_journal_once(&manager).await;
|
||||||
|
assert_eq!(replayed, 0, "damaged committed state must fail closed");
|
||||||
|
assert_eq!(
|
||||||
|
manager.operations_snapshot().await.queued_by_source.mrf,
|
||||||
|
0,
|
||||||
|
"stale legacy bytes must not be replayed when committed state is ambiguous"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
journal_exists_on_all_disks(&disk_paths, COMMITTED_MANIFEST_REL)
|
||||||
|
&& journal_exists_on_all_disks(&disk_paths, COMMITTED_PAYLOAD_REL)
|
||||||
|
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &stale_legacy)
|
||||||
|
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &stale_legacy),
|
||||||
|
"all recovery anchors must remain after a fail-closed committed read"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A canonical snapshot and its compatibility mirror may differ after a
|
/// A canonical snapshot and its compatibility mirror may differ after a
|
||||||
/// partial flush. Replay must choose the complete canonical epoch instead of
|
/// partial flush. Replay must choose the complete canonical epoch instead of
|
||||||
/// combining records that never coexisted in memory.
|
/// combining records that never coexisted in memory.
|
||||||
@@ -322,21 +488,24 @@ async fn authoritative_journal_is_not_merged_with_legacy_mirror() {
|
|||||||
assert_eq!(snapshot.queued_by_source.mrf, 1);
|
assert_eq!(snapshot.queued_by_source.mrf, 1);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths.iter().all(|path| {
|
disk_paths.iter().all(|path| {
|
||||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||||
}),
|
}),
|
||||||
"replay cleanup must remove both journal paths"
|
"accepted replay responsibilities remain anchored until a verified repair proof"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A scoped-only snapshot is valid during a rollout where no legacy
|
// A scoped-only snapshot is valid during a rollout where no legacy
|
||||||
// compatibility mirror was written. Missing legacy files must not leave
|
// compatibility mirror was written. Missing legacy files must not leave
|
||||||
// the runtime in a permanent cleanup-retry state.
|
// the runtime in a permanent cleanup-retry state.
|
||||||
|
let (disk_paths, storage) = heal_env().await;
|
||||||
|
register_local_disks(&disk_paths, "mrf-scoped-authoritative-test").await;
|
||||||
|
let manager = make_manager(storage);
|
||||||
let scoped_only = journal_record(1, "scoped-only-bucket", "scoped-only-object", None, 0);
|
let scoped_only = journal_record(1, "scoped-only-bucket", "scoped-only-object", None, 0);
|
||||||
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &scoped_only);
|
write_journal_path_to_disks(&disk_paths, SCOPED_JOURNAL_REL, &scoped_only);
|
||||||
assert_eq!(mrf_queue::replay_journal_once(&manager).await, 1);
|
assert_eq!(mrf_queue::replay_journal_once(&manager).await, 1);
|
||||||
assert!(disk_paths.iter().all(|path| {
|
assert!(disk_paths.iter().all(|path| {
|
||||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let scoped_v2 = scoped_journal_record(1, "scoped-v2-bucket", "scoped-v2-object", None, 0, 3, 7);
|
let scoped_v2 = scoped_journal_record(1, "scoped-v2-bucket", "scoped-v2-object", None, 0, 3, 7);
|
||||||
@@ -350,12 +519,12 @@ async fn authoritative_journal_is_not_merged_with_legacy_mirror() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manager.operations_snapshot().await.queued_by_source.mrf,
|
manager.operations_snapshot().await.queued_by_source.mrf,
|
||||||
3,
|
2,
|
||||||
"only the three authoritative/scoped-only epochs should have reached the manager"
|
"only the scoped-only and scoped-v2 authoritative epochs should have reached the manager"
|
||||||
);
|
);
|
||||||
assert!(disk_paths.iter().all(|path| {
|
assert!(disk_paths.iter().all(|path| {
|
||||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +569,13 @@ async fn authoritative_journal_replay_preserves_kind_and_scope_identity() {
|
|||||||
snapshot.queued_by_priority.urgent, 1,
|
snapshot.queued_by_priority.urgent, 1,
|
||||||
"decode-failure repair must not merge with object repair responsibility"
|
"decode-failure repair must not merge with object repair responsibility"
|
||||||
);
|
);
|
||||||
assert!(disk_paths.iter().all(|path| {
|
assert!(
|
||||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
disk_paths.iter().all(|path| {
|
||||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||||
}));
|
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||||
|
}),
|
||||||
|
"partial-write responsibilities keep both replay anchors until proof"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If replay reaches a full heal-manager queue, the old journal remains the
|
/// If replay reaches a full heal-manager queue, the old journal remains the
|
||||||
@@ -470,6 +642,73 @@ async fn journal_replay_retains_file_when_manager_is_full() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rollback mirrors are for v1 readers only: the committed and scoped
|
||||||
|
/// snapshots remain authoritative, while the legacy journal omits scoped-only
|
||||||
|
/// records that an older binary cannot represent safely.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[serial]
|
||||||
|
async fn rollback_legacy_mirror_persists_only_v1_compatible_records() {
|
||||||
|
let (disk_paths, storage) = heal_env().await;
|
||||||
|
register_local_disks(&disk_paths, "mrf-rollback-mirror-test").await;
|
||||||
|
|
||||||
|
let manager = Arc::new(HealManager::new(
|
||||||
|
storage,
|
||||||
|
Some(HealConfig {
|
||||||
|
queue_size: 0,
|
||||||
|
heal_interval: Duration::from_secs(3600),
|
||||||
|
enable_auto_heal: false,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
mrf_queue::spawn_mrf_consumer(manager.clone());
|
||||||
|
|
||||||
|
let scoped_only = b"rollback-scoped-only-object";
|
||||||
|
let v1_compatible = b"rollback-v1-compatible-object";
|
||||||
|
assert_eq!(
|
||||||
|
mrf_channel::try_send_mrf_intent_typed(
|
||||||
|
MrfKind::PartialWrite,
|
||||||
|
"rollback-bucket",
|
||||||
|
std::str::from_utf8(scoped_only).expect("fixture object is UTF-8"),
|
||||||
|
None,
|
||||||
|
Some(MrfScope {
|
||||||
|
pool_index: 3,
|
||||||
|
set_index: 7,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
MrfIngressResult::Enqueued,
|
||||||
|
"scoped-only intent should be accepted by the live consumer"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mrf_channel::try_send_mrf_intent_typed(
|
||||||
|
MrfKind::PartialWrite,
|
||||||
|
"rollback-bucket",
|
||||||
|
std::str::from_utf8(v1_compatible).expect("fixture object is UTF-8"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
MrfIngressResult::Enqueued,
|
||||||
|
"v1-compatible intent should be accepted by the live consumer"
|
||||||
|
);
|
||||||
|
|
||||||
|
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||||
|
committed_payload_contains_on_all_disks(&disk_paths, &[scoped_only, v1_compatible])
|
||||||
|
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, scoped_only)
|
||||||
|
&& journal_contains_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, v1_compatible)
|
||||||
|
&& journal_contains_on_all_disks(&disk_paths, JOURNAL_REL, v1_compatible)
|
||||||
|
&& !journal_contains_on_any_disk(&disk_paths, JOURNAL_REL, scoped_only)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
flushed,
|
||||||
|
"runtime flush must persist rollback-safe mirrors without leaking scoped-only records into the legacy journal"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manager.operations_snapshot().await.queued_by_source.mrf,
|
||||||
|
0,
|
||||||
|
"zero-capacity manager keeps both intents in the MRF runtime so the persisted snapshot is observable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mrf_journal_child_process_fixture() {
|
fn mrf_journal_child_process_fixture() {
|
||||||
let Ok(root) = std::env::var("RUSTFS_MRF_REPLAY_CHILD_ROOT") else {
|
let Ok(root) = std::env::var("RUSTFS_MRF_REPLAY_CHILD_ROOT") else {
|
||||||
@@ -524,13 +763,14 @@ fn mrf_successor_flush_child_process_fixture() {
|
|||||||
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
let expected_successor = journal_record(1, "successor-bucket", "second-object", None, 2);
|
||||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||||
|
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
||||||
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
|
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
|
||||||
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
|
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
flushed,
|
flushed,
|
||||||
"child process must publish the pending successor snapshot before the delete phase"
|
"child process must publish the committed pending successor before the delete phase"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
std::process::exit(78);
|
std::process::exit(78);
|
||||||
@@ -571,13 +811,14 @@ fn mrf_successor_flush_waiting_child_process_fixture() {
|
|||||||
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
let expected_successor = journal_record(1, "service-kill-bucket", "second-object", None, 2);
|
||||||
let flushed = wait_until(Duration::from_secs(10), || async {
|
let flushed = wait_until(Duration::from_secs(10), || async {
|
||||||
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
manager.operations_snapshot().await.queued_by_source.mrf == 1
|
||||||
|
&& committed_checkpoint_matches_on_all_disks(&disk_paths, 2, &expected_successor)
|
||||||
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
|
&& journal_matches_on_all_disks(&disk_paths, SCOPED_JOURNAL_REL, &expected_successor)
|
||||||
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
|
&& journal_matches_on_all_disks(&disk_paths, JOURNAL_REL, &expected_successor)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
assert!(
|
assert!(
|
||||||
flushed,
|
flushed,
|
||||||
"child process must publish the pending successor snapshot before it can be killed"
|
"child process must publish the committed pending successor before it can be killed"
|
||||||
);
|
);
|
||||||
std::fs::write(&ready_path, b"ready").expect("write ready marker");
|
std::fs::write(&ready_path, b"ready").expect("write ready marker");
|
||||||
loop {
|
loop {
|
||||||
@@ -698,10 +939,10 @@ async fn journal_replay_survives_successor_flush_before_delete() {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths.iter().all(|path| {
|
disk_paths.iter().all(|path| {
|
||||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||||
}),
|
}),
|
||||||
"a fully consumed successor snapshot may be deleted after restart replay"
|
"the accepted successor remains anchored until a verified repair proof"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,10 +992,10 @@ async fn journal_replay_survives_service_kill_after_successor_flush() {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths.iter().all(|path| {
|
disk_paths.iter().all(|path| {
|
||||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||||
}),
|
}),
|
||||||
"a fully consumed successor snapshot may be deleted after service-kill restart replay"
|
"the accepted successor remains anchored until a verified repair proof after service-kill restart"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,9 +1055,9 @@ async fn journal_replay_survives_sigkill_after_authoritative_successor_fsync_bef
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
disk_paths.iter().all(|path| {
|
disk_paths.iter().all(|path| {
|
||||||
!Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
Path::new(path).join(META_BUCKET).join(JOURNAL_REL).exists()
|
||||||
&& !Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
&& Path::new(path).join(META_BUCKET).join(SCOPED_JOURNAL_REL).exists()
|
||||||
}),
|
}),
|
||||||
"a fully consumed authoritative successor may clean both epochs after restart replay"
|
"the accepted authoritative successor remains anchored until a verified repair proof"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,51 @@ pub fn record_contention_event() {
|
|||||||
counter!("rustfs_lock_contentions").increment(1);
|
counter!("rustfs_lock_contentions").increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a remote lock RPC that exceeded its caller's deadline.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_rpc_timeout(peer: &str, op: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_rpc_timeouts_total", "peer" => peer.to_string(), "op" => op).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the cached lock channel to `peer` being evicted after an RPC failure.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_channel_eviction(peer: &str, trigger: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_channel_evictions_total", "peer" => peer.to_string(), "trigger" => trigger).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an RPC failure that did not evict the cached lock channel to `peer`
|
||||||
|
/// because the peer recently served a request or was re-dialed too recently.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_channel_eviction_suppressed(peer: &str, verdict: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_channel_evictions_suppressed_total", "peer" => peer.to_string(), "verdict" => verdict)
|
||||||
|
.increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a timed-out lock RPC that was left running (`detached`) or cancelled
|
||||||
|
/// because the per-peer detached budget was exhausted (`aborted`).
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_rpc_detached(op: &'static str, outcome: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_rpc_detached_total", "op" => op, "outcome" => outcome).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record how a detached lock RPC eventually ended.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_rpc_late_completion(op: &'static str, outcome: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_rpc_late_completions_total", "op" => op, "outcome" => outcome).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the release of a lock that was granted after its caller timed out.
|
||||||
|
#[inline(always)]
|
||||||
|
pub fn record_remote_lock_late_release(outcome: &'static str) {
|
||||||
|
use metrics::counter;
|
||||||
|
counter!("rustfs_remote_lock_late_releases_total", "outcome" => outcome).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Record object namespace lock diagnostics being enabled.
|
/// Record object namespace lock diagnostics being enabled.
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub fn record_object_lock_diag_enabled(enabled: bool) {
|
pub fn record_object_lock_diag_enabled(enabled: bool) {
|
||||||
@@ -183,6 +228,12 @@ mod tests {
|
|||||||
record_lock_hold_time(Duration::from_millis(100));
|
record_lock_hold_time(Duration::from_millis(100));
|
||||||
record_early_release();
|
record_early_release();
|
||||||
record_contention_event();
|
record_contention_event();
|
||||||
|
record_remote_lock_rpc_timeout("http://peer:9000", "lock");
|
||||||
|
record_remote_lock_channel_eviction("http://peer:9000", "timeout");
|
||||||
|
record_remote_lock_channel_eviction_suppressed("http://peer:9000", "peer_recently_served");
|
||||||
|
record_remote_lock_rpc_detached("lock", "detached");
|
||||||
|
record_remote_lock_rpc_late_completion("lock", "success");
|
||||||
|
record_remote_lock_late_release("released");
|
||||||
});
|
});
|
||||||
|
|
||||||
let emitted: std::collections::HashSet<String> = snapshotter
|
let emitted: std::collections::HashSet<String> = snapshotter
|
||||||
@@ -199,6 +250,12 @@ mod tests {
|
|||||||
"rustfs_lock_hold_time_secs",
|
"rustfs_lock_hold_time_secs",
|
||||||
"rustfs_lock_early_releases",
|
"rustfs_lock_early_releases",
|
||||||
"rustfs_lock_contentions",
|
"rustfs_lock_contentions",
|
||||||
|
"rustfs_remote_lock_rpc_timeouts_total",
|
||||||
|
"rustfs_remote_lock_channel_evictions_total",
|
||||||
|
"rustfs_remote_lock_channel_evictions_suppressed_total",
|
||||||
|
"rustfs_remote_lock_rpc_detached_total",
|
||||||
|
"rustfs_remote_lock_rpc_late_completions_total",
|
||||||
|
"rustfs_remote_lock_late_releases_total",
|
||||||
] {
|
] {
|
||||||
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
assert!(emitted.contains(expected), "{expected} must be emitted by its record helper");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -819,7 +819,9 @@ impl KmsBackend for AwsKmsBackend {
|
|||||||
.with_rotate(true)
|
.with_rotate(true)
|
||||||
.with_enable_disable(true)
|
.with_enable_disable(true)
|
||||||
.with_schedule_deletion(true)
|
.with_schedule_deletion(true)
|
||||||
.with_versioning(true)
|
// AWS KMS exposes rotation state but does not enumerate key
|
||||||
|
// versions through this backend's API contract.
|
||||||
|
.with_versioning(false)
|
||||||
.with_physical_delete(false)
|
.with_physical_delete(false)
|
||||||
.with_production_supported(true)
|
.with_production_supported(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1233,6 +1233,9 @@ impl LocalKmsClient {
|
|||||||
async fn decode_stored_key(&self, key_id: &str) -> Result<(StoredMasterKey, Vec<u8>)> {
|
async fn decode_stored_key(&self, key_id: &str) -> Result<(StoredMasterKey, Vec<u8>)> {
|
||||||
let key_path = self.master_key_path(key_id)?;
|
let key_path = self.master_key_path(key_id)?;
|
||||||
if !fs::try_exists(&key_path).await? {
|
if !fs::try_exists(&key_path).await? {
|
||||||
|
// Only an accessible key store can establish that a single key is
|
||||||
|
// missing; a directory outage must retain its filesystem error.
|
||||||
|
let _ = fs::read_dir(&self.config.key_dir).await?;
|
||||||
return Err(KmsError::key_not_found(key_id));
|
return Err(KmsError::key_not_found(key_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2387,6 +2390,76 @@ mod tests {
|
|||||||
(client, temp_dir)
|
(client, temp_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_key_directory_outage_is_io_error_and_recovers_original_key() {
|
||||||
|
let root = TempDir::new().expect("create isolated key store");
|
||||||
|
let key_dir = root.path().join("keys");
|
||||||
|
let unavailable_dir = root.path().join("keys-unavailable");
|
||||||
|
let config = KmsConfig::local(key_dir.clone()).with_insecure_development_defaults();
|
||||||
|
let backend = LocalKmsBackend::new(config).await.expect("start Local KMS");
|
||||||
|
let key_id = "directory-outage-key";
|
||||||
|
backend
|
||||||
|
.create_key(CreateKeyRequest {
|
||||||
|
key_name: Some(key_id.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create the original key");
|
||||||
|
let request = |key_id: &str| GenerateDataKeyRequest {
|
||||||
|
key_id: key_id.to_string(),
|
||||||
|
key_spec: KeySpec::Aes256,
|
||||||
|
encryption_context: HashMap::new(),
|
||||||
|
};
|
||||||
|
let before = backend
|
||||||
|
.generate_data_key(request(key_id))
|
||||||
|
.await
|
||||||
|
.expect("generate a data key before the outage");
|
||||||
|
let missing_key = backend.generate_data_key(request("no-such-key")).await;
|
||||||
|
let key_path = key_dir.join(format!("{key_id}.key"));
|
||||||
|
let original_record = fs::read(&key_path).await.expect("read the original key record");
|
||||||
|
|
||||||
|
fs::rename(&key_dir, &unavailable_dir)
|
||||||
|
.await
|
||||||
|
.expect("make the key directory unavailable");
|
||||||
|
let unavailable = backend.generate_data_key(request(key_id)).await;
|
||||||
|
// Restore before checking the error so the failing regression leaves no
|
||||||
|
// orphaned key store; both paths also belong to the same temporary root.
|
||||||
|
fs::rename(&unavailable_dir, &key_dir)
|
||||||
|
.await
|
||||||
|
.expect("restore the original key directory");
|
||||||
|
|
||||||
|
let after = backend
|
||||||
|
.generate_data_key(request(key_id))
|
||||||
|
.await
|
||||||
|
.expect("generate a data key after directory restoration");
|
||||||
|
for data_key in [&before, &after] {
|
||||||
|
let decrypted = backend
|
||||||
|
.decrypt(DecryptRequest {
|
||||||
|
ciphertext: data_key.ciphertext_blob.clone(),
|
||||||
|
encryption_context: HashMap::new(),
|
||||||
|
grant_tokens: Vec::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("the original master key must decrypt both data keys");
|
||||||
|
assert!(
|
||||||
|
decrypted.plaintext == data_key.plaintext_key,
|
||||||
|
"directory restoration must preserve the original key material"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
fs::read(&key_path).await.expect("read the restored key record") == original_record,
|
||||||
|
"reads and recovery must not rewrite the key record"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(missing_key, Err(KmsError::KeyNotFound { key_id }) if key_id == "no-such-key"),
|
||||||
|
"a missing key in a readable directory must remain KeyNotFound"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(unavailable, Err(KmsError::IoError { .. })),
|
||||||
|
"an unavailable key directory must remain an I/O error, not KeyNotFound"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// With the AAD write switch on, the Local backend seals the stored
|
/// With the AAD write switch on, the Local backend seals the stored
|
||||||
/// encryption context into the wrap exactly like KV2: the bound envelope
|
/// encryption context into the wrap exactly like KV2: the bound envelope
|
||||||
/// round-trips, a rewritten stored context fails authentication even with
|
/// round-trips, a rewritten stored context fails authentication even with
|
||||||
|
|||||||
@@ -74,6 +74,39 @@ impl ScriptedResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `auth/token/lookup-self` answer every scripted Vault serves for free.
|
||||||
|
///
|
||||||
|
/// A Vault client now probes its token's remaining lifetime at login
|
||||||
|
/// (backlog#2369 P3), which is credential plumbing rather than the protocol any
|
||||||
|
/// of these tests is scripting. Answering it out of band keeps every existing
|
||||||
|
/// script meaningful: `ttl` 0 is Vault's "this token does not expire", so the
|
||||||
|
/// probe changes nothing about how a scripted test behaves.
|
||||||
|
pub(crate) fn token_lookup_self_response() -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"data": {
|
||||||
|
"accessor": "scripted-accessor",
|
||||||
|
"creation_time": 1_700_000_000u64,
|
||||||
|
"creation_ttl": 0,
|
||||||
|
"display_name": "token",
|
||||||
|
"entity_id": "",
|
||||||
|
"explicit_max_ttl": 0,
|
||||||
|
"id": "scripted-token",
|
||||||
|
"num_uses": 0,
|
||||||
|
"orphan": true,
|
||||||
|
"path": "auth/token/create",
|
||||||
|
"policies": ["default"],
|
||||||
|
"renewable": false,
|
||||||
|
"ttl": 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a recorded request line addresses the token self-lookup.
|
||||||
|
pub(crate) fn is_token_lookup_self(request_line: &str) -> bool {
|
||||||
|
request_line.contains("/v1/auth/token/lookup-self")
|
||||||
|
}
|
||||||
|
|
||||||
/// A scripted stand-in Vault listening on a loopback port.
|
/// A scripted stand-in Vault listening on a loopback port.
|
||||||
pub(crate) struct ScriptedVault {
|
pub(crate) struct ScriptedVault {
|
||||||
/// Base address (`http://127.0.0.1:port`) to point a Vault client at.
|
/// Base address (`http://127.0.0.1:port`) to point a Vault client at.
|
||||||
@@ -102,6 +135,19 @@ impl ScriptedVault {
|
|||||||
let Some((request_line, body, mut stream)) = read_request(stream).await else {
|
let Some((request_line, body, mut stream)) = read_request(stream).await else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if is_token_lookup_self(&request_line) {
|
||||||
|
// Served out of band so the credential probe does not
|
||||||
|
// consume a scripted response meant for the protocol under
|
||||||
|
// test, and is not recorded as one of its requests.
|
||||||
|
let body = token_lookup_self_response();
|
||||||
|
let payload = format!(
|
||||||
|
"HTTP/1.1 200 Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||||
|
body.len(),
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(payload.as_bytes()).await;
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
recorded
|
recorded
|
||||||
.lock()
|
.lock()
|
||||||
.expect("scripted vault request log poisoned")
|
.expect("scripted vault request log poisoned")
|
||||||
@@ -155,6 +201,19 @@ impl ScriptedVault {
|
|||||||
let Some((request_line, body, stream)) = read_request(stream).await else {
|
let Some((request_line, body, stream)) = read_request(stream).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if is_token_lookup_self(&request_line) {
|
||||||
|
// Credential plumbing, not part of the KV2 protocol
|
||||||
|
// this responder models; see token_lookup_self_response.
|
||||||
|
write_response(
|
||||||
|
stream,
|
||||||
|
ScriptedResponse::Http {
|
||||||
|
status: 200,
|
||||||
|
body: token_lookup_self_response(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
recorded
|
recorded
|
||||||
.lock()
|
.lock()
|
||||||
.expect("scripted vault request log poisoned")
|
.expect("scripted vault request log poisoned")
|
||||||
|
|||||||
+1
-1
@@ -13,5 +13,5 @@ expression: capabilities_snapshot(backend.capabilities())
|
|||||||
"rotate": true,
|
"rotate": true,
|
||||||
"schedule_deletion": true,
|
"schedule_deletion": true,
|
||||||
"update_key_metadata": false,
|
"update_key_metadata": false,
|
||||||
"versioning": true
|
"versioning": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ impl fmt::Debug for SecretString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Expiry attributes of a lease-bound token.
|
/// Expiry attributes of a lease-bound token.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) struct LeaseInfo {
|
pub(crate) struct LeaseInfo {
|
||||||
/// Time-to-live granted at issue or renewal.
|
/// Time-to-live granted at issue or renewal.
|
||||||
pub(crate) ttl: Duration,
|
pub(crate) ttl: Duration,
|
||||||
@@ -207,31 +207,102 @@ pub(crate) trait TokenSource: fmt::Debug + Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed
|
/// Token source for [`VaultAuthMethod::Token`]: always yields the token fixed
|
||||||
/// at configuration time. The token carries no lease, so it is never renewed
|
/// at configuration time.
|
||||||
/// and never expires from the provider's point of view.
|
///
|
||||||
|
/// The token itself is never re-issued, but it usually still expires:
|
||||||
|
/// `vault token create` defaults to a 768-hour TTL. Hard-coding "no lease"
|
||||||
|
/// here left the renewal task unstarted and published no remaining-TTL gauge,
|
||||||
|
/// so a healthy-looking cluster turned every KMS call into a 403 a month later
|
||||||
|
/// and could only be recovered by a restart or a reconfigure (backlog#2369 P3).
|
||||||
|
/// The source therefore asks Vault what it is holding, once per client
|
||||||
|
/// generation, and lets the existing renewal loop take over whenever the answer
|
||||||
|
/// carries a TTL.
|
||||||
|
/// Map a `lookup-self` answer onto a lease.
|
||||||
|
///
|
||||||
|
/// A zero TTL is Vault's answer for a token that never expires (root and
|
||||||
|
/// periodic-root tokens), which keeps the pre-probe behaviour exactly: no
|
||||||
|
/// lease, no renewal task, no expiry gate. A response that omits `renewable`
|
||||||
|
/// is treated as not renewable, so the renewal loop falls back to re-reading
|
||||||
|
/// the remaining TTL instead of assuming it can extend it.
|
||||||
|
fn static_token_lease(ttl_secs: u64, renewable: Option<bool>) -> Option<LeaseInfo> {
|
||||||
|
(ttl_secs > 0).then_some(LeaseInfo {
|
||||||
|
ttl: Duration::from_secs(ttl_secs),
|
||||||
|
renewable: renewable.unwrap_or(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct StaticToken {
|
pub(crate) struct StaticToken {
|
||||||
token: TokenLease,
|
token: TokenLease,
|
||||||
|
/// Client authenticated with the configured token, used only for
|
||||||
|
/// `lookup-self`. Per-generation renewals use the generation's own client.
|
||||||
|
lookup_client: VaultClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StaticToken {
|
impl StaticToken {
|
||||||
pub(crate) fn new(token: String) -> Self {
|
pub(crate) fn new(settings: &VaultConnectionSettings, token: String) -> Result<Self> {
|
||||||
Self {
|
let lookup_client = settings.build_client(&token)?;
|
||||||
|
Ok(Self {
|
||||||
token: TokenLease::new(token, None),
|
token: TokenLease::new(token, None),
|
||||||
}
|
lookup_client,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TokenSource for StaticToken {
|
impl TokenSource for StaticToken {
|
||||||
async fn acquire(&self) -> AttemptResult<TokenLease> {
|
async fn acquire(&self) -> AttemptResult<TokenLease> {
|
||||||
Ok(self.token.clone())
|
// A lookup failure must not fail the login. The token itself may well
|
||||||
|
// be valid: a policy can omit `lookup-self`, and Vault may simply be
|
||||||
|
// unreachable for the moment. Failing here would take down deployments
|
||||||
|
// that work today, so the probe degrades to the pre-probe behaviour —
|
||||||
|
// no lease, no renewal — and says so loudly instead.
|
||||||
|
let lease = match vaultrs::token::lookup_self(&self.lookup_client).await {
|
||||||
|
Ok(lookup) => static_token_lease(lookup.ttl, lookup.renewable),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
event = "vault_static_token_lookup_failed",
|
||||||
|
error = %error,
|
||||||
|
"Could not read the configured Vault token's remaining lifetime, so it will not be \
|
||||||
|
renewed and its expiry will not be tracked. Grant the token `lookup-self` (Vault's \
|
||||||
|
default policy does) or switch to AppRole, Kubernetes or an agent-managed token file"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(lease) = lease
|
||||||
|
&& !lease.renewable
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
event = "vault_static_token_not_renewable",
|
||||||
|
ttl_secs = lease.ttl.as_secs(),
|
||||||
|
"The configured Vault token expires and cannot be renewed; RustFS will fail closed as it \
|
||||||
|
approaches expiry. Switch to AppRole, Kubernetes or an agent-managed token file, or \
|
||||||
|
reconfigure with a fresh token before it lapses"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TokenLease::new(self.token.expose().to_string(), lease))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn renew(&self, client: &VaultClient) -> AttemptResult<TokenLease> {
|
||||||
|
// Vault refuses renew-self on a non-renewable token; the renewal loop
|
||||||
|
// then falls back to `acquire`, which re-reads the remaining TTL and
|
||||||
|
// keeps the gauge honest until the fail-closed window is reached.
|
||||||
|
let auth = vaultrs::token::renew_self(client, None)
|
||||||
|
.await
|
||||||
|
.map_err(|error| attempt_error("token renewal", error))?;
|
||||||
|
Ok(TokenLease::from_auth(auth))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for StaticToken {
|
impl fmt::Debug for StaticToken {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
// TokenLease::fmt already redacts the token value.
|
// TokenLease::fmt already redacts the token value; VaultClient embeds
|
||||||
f.debug_struct("StaticToken").field("token", &self.token).finish()
|
// its settings, including the token, so it must stay out of Debug.
|
||||||
|
f.debug_struct("StaticToken")
|
||||||
|
.field("token", &self.token)
|
||||||
|
.finish_non_exhaustive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +612,7 @@ pub(crate) fn token_source_for(
|
|||||||
settings: &VaultConnectionSettings,
|
settings: &VaultConnectionSettings,
|
||||||
) -> Result<Box<dyn TokenSource>> {
|
) -> Result<Box<dyn TokenSource>> {
|
||||||
match auth_method {
|
match auth_method {
|
||||||
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(token.clone()))),
|
VaultAuthMethod::Token { token } => Ok(Box::new(StaticToken::new(settings, token.clone())?)),
|
||||||
VaultAuthMethod::AppRole {
|
VaultAuthMethod::AppRole {
|
||||||
role_id,
|
role_id,
|
||||||
secret_id,
|
secret_id,
|
||||||
@@ -1201,14 +1272,22 @@ mod tests {
|
|||||||
(Arc::new(provider), state)
|
(Arc::new(provider), state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A provider whose token reports no expiry, which is what `lookup-self`
|
||||||
|
/// answers for a root or periodic-root token. Scripted rather than backed
|
||||||
|
/// by [`StaticToken`] because the real source now asks Vault what it holds.
|
||||||
async fn static_provider() -> VaultCredentialProvider {
|
async fn static_provider() -> VaultCredentialProvider {
|
||||||
VaultCredentialProvider::new(
|
VaultCredentialProvider::new(
|
||||||
test_settings(),
|
test_settings(),
|
||||||
Box::new(StaticToken::new(TEST_TOKEN.to_string())),
|
Box::new(ScriptedSource {
|
||||||
|
state: Arc::new(ScriptedState::default()),
|
||||||
|
ttl: Duration::ZERO,
|
||||||
|
renewable: false,
|
||||||
|
login_delay: Duration::ZERO,
|
||||||
|
}),
|
||||||
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
|
test_policy(Duration::from_secs(10), Duration::from_secs(5)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("static provider must build without a live Vault")
|
.expect("a token without an expiry must build without a live Vault")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1231,20 +1310,57 @@ mod tests {
|
|||||||
assert!(provider.spawn_renewal_task().is_none(), "a token without a lease has nothing to renew");
|
assert!(provider.spawn_renewal_task().is_none(), "a token without a lease has nothing to renew");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[test]
|
||||||
async fn test_static_token_source_yields_configured_token() {
|
fn test_static_token_source_builds_without_contacting_vault() {
|
||||||
let settings = test_settings();
|
token_source_for(
|
||||||
let source = token_source_for(
|
|
||||||
&VaultAuthMethod::Token {
|
&VaultAuthMethod::Token {
|
||||||
token: TEST_TOKEN.to_string(),
|
token: TEST_TOKEN.to_string(),
|
||||||
},
|
},
|
||||||
&settings,
|
&test_settings(),
|
||||||
)
|
)
|
||||||
.expect("token auth must map to a source");
|
.expect("token auth must map to a source");
|
||||||
|
}
|
||||||
|
|
||||||
let lease = source.acquire().await.expect("static acquire cannot fail");
|
/// backlog#2369 P3: `vault token create` defaults to a 768-hour TTL, so
|
||||||
assert_eq!(lease.expose(), TEST_TOKEN);
|
/// hard-coding "no lease" for token auth left the renewal task unstarted
|
||||||
assert!(lease.lease_info().is_none(), "static tokens must not carry a lease");
|
/// and turned a healthy cluster into one that answers 403 a month later.
|
||||||
|
/// The lease now comes from what Vault reports.
|
||||||
|
#[test]
|
||||||
|
fn static_token_lease_follows_what_vault_reports() {
|
||||||
|
assert_eq!(
|
||||||
|
static_token_lease(0, Some(true)),
|
||||||
|
None,
|
||||||
|
"a token Vault reports as non-expiring must keep behaving as one"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
static_token_lease(0, None),
|
||||||
|
None,
|
||||||
|
"a non-expiring token stays non-expiring whatever renewable says"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
static_token_lease(2_764_800, Some(true)),
|
||||||
|
Some(LeaseInfo {
|
||||||
|
ttl: Duration::from_secs(2_764_800),
|
||||||
|
renewable: true,
|
||||||
|
}),
|
||||||
|
"the default 768-hour token must be tracked and renewed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
static_token_lease(3_600, Some(false)),
|
||||||
|
Some(LeaseInfo {
|
||||||
|
ttl: Duration::from_secs(3_600),
|
||||||
|
renewable: false,
|
||||||
|
}),
|
||||||
|
"an expiring token that cannot be renewed still needs its expiry tracked"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
static_token_lease(3_600, None),
|
||||||
|
Some(LeaseInfo {
|
||||||
|
ttl: Duration::from_secs(3_600),
|
||||||
|
renewable: false,
|
||||||
|
}),
|
||||||
|
"an omitted renewable flag must not be read as renewable"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1759,7 +1875,7 @@ mod tests {
|
|||||||
renewable: true,
|
renewable: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let static_source = StaticToken::new(TEST_TOKEN.to_string());
|
let static_source = StaticToken::new(&test_settings(), TEST_TOKEN.to_string()).expect("static source");
|
||||||
let approle_source = AppRoleLogin::new(
|
let approle_source = AppRoleLogin::new(
|
||||||
&test_settings(),
|
&test_settings(),
|
||||||
"approle".to_string(),
|
"approle".to_string(),
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
const UNLOCK_RETRY_ATTEMPTS: usize = 3;
|
const UNLOCK_RETRY_ATTEMPTS: usize = 3;
|
||||||
const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
const UNLOCK_RETRY_BACKOFF: Duration = Duration::from_millis(100);
|
||||||
|
/// Slow retry schedule for unlocks that survive the fast retry loop. Lock RPC
|
||||||
|
/// timeouts under load are transient (issue #7363); giving up after three
|
||||||
|
/// quick attempts left orphaned entries for the server lease to expire.
|
||||||
|
const DEFERRED_UNLOCK_BACKOFF: [Duration; 5] = [
|
||||||
|
Duration::from_secs(1),
|
||||||
|
Duration::from_secs(2),
|
||||||
|
Duration::from_secs(4),
|
||||||
|
Duration::from_secs(8),
|
||||||
|
Duration::from_secs(16),
|
||||||
|
];
|
||||||
const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
|
const LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(250);
|
||||||
const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
|
const LOCK_ACQUIRE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
const LOCK_ACQUIRE_SPARE_HEDGES: usize = 1;
|
const LOCK_ACQUIRE_SPARE_HEDGES: usize = 1;
|
||||||
@@ -719,6 +729,31 @@ impl DistributedLock {
|
|||||||
let mut pending = entries;
|
let mut pending = entries;
|
||||||
|
|
||||||
for attempt in 1..=UNLOCK_RETRY_ATTEMPTS {
|
for attempt in 1..=UNLOCK_RETRY_ATTEMPTS {
|
||||||
|
pending = Self::release_pending_once(pending, attempt, context).await;
|
||||||
|
if pending.is_empty() {
|
||||||
|
debug!(attempt, context, "distributed unlock completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < UNLOCK_RETRY_ATTEMPTS {
|
||||||
|
tokio::time::sleep(UNLOCK_RETRY_BACKOFF * attempt as u32).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
warn!(
|
||||||
|
remaining = pending.len(),
|
||||||
|
attempts = UNLOCK_RETRY_ATTEMPTS,
|
||||||
|
context,
|
||||||
|
"distributed unlock left unreleased entries after retry; continuing with deferred retries"
|
||||||
|
);
|
||||||
|
Self::release_entries_deferred(pending, context).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_pending_once(
|
||||||
|
pending: Vec<(LockId, Arc<dyn LockClient>)>,
|
||||||
|
attempt: usize,
|
||||||
|
context: &'static str,
|
||||||
|
) -> Vec<(LockId, Arc<dyn LockClient>)> {
|
||||||
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
let release_results = join_all(pending.into_iter().map(|(lock_id, client)| async move {
|
||||||
match client.release(&lock_id).await {
|
match client.release(&lock_id).await {
|
||||||
Ok(true) => None,
|
Ok(true) => None,
|
||||||
@@ -734,22 +769,29 @@ impl DistributedLock {
|
|||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
pending = release_results.into_iter().flatten().collect();
|
release_results.into_iter().flatten().collect()
|
||||||
if pending.is_empty() {
|
|
||||||
debug!(attempt, context, "distributed unlock completed");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if attempt < UNLOCK_RETRY_ATTEMPTS {
|
/// Bounded slow retries for entries the fast loop could not release. Every
|
||||||
tokio::time::sleep(UNLOCK_RETRY_BACKOFF * attempt as u32).await;
|
/// caller runs on a background task, so waiting here blocks nobody; after
|
||||||
|
/// the schedule is exhausted the server lease reclaims the entry.
|
||||||
|
async fn release_entries_deferred(mut pending: Vec<(LockId, Arc<dyn LockClient>)>, context: &'static str) {
|
||||||
|
let mut attempt = UNLOCK_RETRY_ATTEMPTS;
|
||||||
|
for delay in DEFERRED_UNLOCK_BACKOFF {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
attempt += 1;
|
||||||
|
pending = Self::release_pending_once(pending, attempt, context).await;
|
||||||
|
if pending.is_empty() {
|
||||||
|
debug!(attempt, context, "deferred distributed unlock converged");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!(
|
warn!(
|
||||||
remaining = pending.len(),
|
remaining = pending.len(),
|
||||||
attempts = UNLOCK_RETRY_ATTEMPTS,
|
attempts = attempt,
|
||||||
context,
|
context,
|
||||||
"distributed unlock left unreleased entries after retry"
|
"distributed unlock abandoned entries after deferred retry; the server lease will expire them"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,7 +837,9 @@ impl DistributedLock {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::release_entries(vec![(lock_id, client.clone())], context).await;
|
// Deferred retries may wait tens of seconds; never hold up the
|
||||||
|
// next late completion behind them.
|
||||||
|
drop(tokio::spawn(Self::release_entries(vec![(lock_id, client.clone())], context)));
|
||||||
}
|
}
|
||||||
Ok((idx, Ok(resp))) => {
|
Ok((idx, Ok(resp))) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -1198,8 +1242,8 @@ fn record_lock_held_release(lock_type: LockType) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF, LockAcquireFailureKind,
|
DEFERRED_UNLOCK_BACKOFF, DistributedLock, LOCK_ACQUIRE_ATTEMPT_TIMEOUT, LOCK_ACQUIRE_RETRY_INITIAL_BACKOFF,
|
||||||
LockLostSignal, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
LockAcquireFailureKind, LockLostSignal, UNLOCK_RETRY_ATTEMPTS, is_remote_lock_rpc_failure, should_warn_lock_failure,
|
||||||
};
|
};
|
||||||
use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient};
|
use crate::{LockError, LockId, LockInfo, LockRequest, LockResponse, LockStats, LockType, ObjectKey, client::LockClient};
|
||||||
use rand::{SeedableRng as _, TryRng, rngs::StdRng};
|
use rand::{SeedableRng as _, TryRng, rngs::StdRng};
|
||||||
@@ -1692,6 +1736,94 @@ mod tests {
|
|||||||
drop(guard);
|
drop(guard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fails `release` a fixed number of times before succeeding, mimicking a
|
||||||
|
/// peer whose lock RPCs time out under load and then recover.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FlakyReleaseClient {
|
||||||
|
failures_left: AtomicUsize,
|
||||||
|
release_calls: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl LockClient for FlakyReleaseClient {
|
||||||
|
async fn acquire_lock(&self, _request: &LockRequest) -> crate::Result<LockResponse> {
|
||||||
|
Ok(LockResponse::failure("unused", Duration::ZERO))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||||
|
self.release_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if self.failures_left.load(Ordering::SeqCst) > 0 {
|
||||||
|
self.failures_left.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
return Err(LockError::internal("remote lock rpc timed out: release"));
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn force_release(&self, _lock_id: &LockId) -> crate::Result<bool> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_status(&self, _lock_id: &LockId) -> crate::Result<Option<LockInfo>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stats(&self) -> crate::Result<LockStats> {
|
||||||
|
Ok(LockStats::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&self) -> crate::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_online(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_local(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn release_entries_keeps_retrying_transient_failures_after_the_fast_loop() {
|
||||||
|
let release_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
||||||
|
failures_left: AtomicUsize::new(UNLOCK_RETRY_ATTEMPTS + 2),
|
||||||
|
release_calls: release_calls.clone(),
|
||||||
|
});
|
||||||
|
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
||||||
|
|
||||||
|
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock").await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
release_calls.load(Ordering::SeqCst),
|
||||||
|
UNLOCK_RETRY_ATTEMPTS + 3,
|
||||||
|
"two deferred attempts fail, the third releases the entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(start_paused = true)]
|
||||||
|
async fn release_entries_gives_up_after_the_deferred_schedule() {
|
||||||
|
let release_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let client: Arc<dyn LockClient> = Arc::new(FlakyReleaseClient {
|
||||||
|
failures_left: AtomicUsize::new(usize::MAX),
|
||||||
|
release_calls: release_calls.clone(),
|
||||||
|
});
|
||||||
|
let lock_id = LockId::new_unique(&ObjectKey::new("bucket", "object"));
|
||||||
|
|
||||||
|
DistributedLock::release_entries(vec![(lock_id, client)], "test_deferred_unlock_abandoned").await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
release_calls.load(Ordering::SeqCst),
|
||||||
|
UNLOCK_RETRY_ATTEMPTS + DEFERRED_UNLOCK_BACKOFF.len(),
|
||||||
|
"the retry budget is bounded; the server lease reclaims what remains"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ResponseClient {
|
struct ResponseClient {
|
||||||
response: LockResponse,
|
response: LockResponse,
|
||||||
|
|||||||
@@ -263,6 +263,14 @@ pub struct ScannerUsageRecoveryIntentResponse {
|
|||||||
pub mode: String,
|
pub mode: String,
|
||||||
pub intent_id: String,
|
pub intent_id: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub actor_sha256: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub idempotency_key_sha256: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub request_sha256: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub accepted_at_unix_secs: Option<u64>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extra: serde_json::Map<String, serde_json::Value>,
|
pub extra: serde_json::Map<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
@@ -869,6 +877,10 @@ mod tests {
|
|||||||
"mode": "full-rebuild",
|
"mode": "full-rebuild",
|
||||||
"intent_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
"intent_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
"state": "accepted",
|
"state": "accepted",
|
||||||
|
"actor_sha256": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"idempotency_key_sha256": "2222222222222222222222222222222222222222222222222222222222222222",
|
||||||
|
"request_sha256": "3333333333333333333333333333333333333333333333333333333333333333",
|
||||||
|
"accepted_at_unix_secs": 7,
|
||||||
"future": {"worker": "pending"}
|
"future": {"worker": "pending"}
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -877,7 +889,33 @@ mod tests {
|
|||||||
assert_eq!(intent.mode, "full-rebuild");
|
assert_eq!(intent.mode, "full-rebuild");
|
||||||
assert_eq!(intent.intent_id, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
assert_eq!(intent.intent_id, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
||||||
assert_eq!(intent.state, "accepted");
|
assert_eq!(intent.state, "accepted");
|
||||||
|
assert_eq!(
|
||||||
|
intent.actor_sha256.as_deref(),
|
||||||
|
Some("1111111111111111111111111111111111111111111111111111111111111111")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
intent.idempotency_key_sha256.as_deref(),
|
||||||
|
Some("2222222222222222222222222222222222222222222222222222222222222222")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
intent.request_sha256.as_deref(),
|
||||||
|
Some("3333333333333333333333333333333333333333333333333333333333333333")
|
||||||
|
);
|
||||||
|
assert_eq!(intent.accepted_at_unix_secs, Some(7));
|
||||||
assert_eq!(intent.extra["future"]["worker"], "pending");
|
assert_eq!(intent.extra["future"]["worker"], "pending");
|
||||||
|
|
||||||
|
let legacy_intent: ScannerUsageRecoveryIntentResponse = serde_json::from_value(json!({
|
||||||
|
"status": "accepted",
|
||||||
|
"action": "usage-full-rebuild",
|
||||||
|
"mode": "full-rebuild",
|
||||||
|
"intent_id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
"state": "accepted"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(legacy_intent.actor_sha256.is_none());
|
||||||
|
assert!(legacy_intent.idempotency_key_sha256.is_none());
|
||||||
|
assert!(legacy_intent.request_sha256.is_none());
|
||||||
|
assert!(legacy_intent.accepted_at_unix_secs.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -971,7 +1009,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scanner_usage_async_reset_posts_explicit_intent_contract() {
|
async fn scanner_usage_async_reset_posts_explicit_intent_contract() {
|
||||||
let server = TestServer::spawn(
|
let server = TestServer::spawn(
|
||||||
r#"{"status":"accepted","action":"usage-full-rebuild","mode":"full-rebuild","intent_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"accepted"}"#,
|
r#"{"status":"accepted","action":"usage-full-rebuild","mode":"full-rebuild","intent_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":"accepted","actor_sha256":"1111111111111111111111111111111111111111111111111111111111111111","idempotency_key_sha256":"2222222222222222222222222222222222222222222222222222222222222222","request_sha256":"3333333333333333333333333333333333333333333333333333333333333333","accepted_at_unix_secs":7}"#,
|
||||||
202,
|
202,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -985,6 +1023,7 @@ mod tests {
|
|||||||
assert_eq!(accepted.status, "accepted");
|
assert_eq!(accepted.status, "accepted");
|
||||||
assert_eq!(accepted.mode, "full-rebuild");
|
assert_eq!(accepted.mode, "full-rebuild");
|
||||||
assert_eq!(accepted.state, "accepted");
|
assert_eq!(accepted.state, "accepted");
|
||||||
|
assert_eq!(accepted.accepted_at_unix_secs, Some(7));
|
||||||
let request = server.recorded();
|
let request = server.recorded();
|
||||||
assert_eq!(request.method, "POST");
|
assert_eq!(request.method, "POST");
|
||||||
assert_eq!(request.path, "/rustfs/admin/v3/scanner/usage-state/reset");
|
assert_eq!(request.path, "/rustfs/admin/v3/scanner/usage-state/reset");
|
||||||
|
|||||||
@@ -166,6 +166,24 @@ fn rule_replicates(rule: &ReplicationRule, obj: &ObjectOpts) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn replication_filter_tags_match(filter: &s3s::dto::ReplicationRuleFilter, object_tags: &HashMap<String, String>) -> bool {
|
||||||
|
let tag_matches = |tag: &s3s::dto::Tag| match (&tag.key, &tag.value) {
|
||||||
|
(None, None) => true,
|
||||||
|
(Some(key), _) if key.is_empty() => true,
|
||||||
|
(Some(key), Some(value)) => object_tags.get(key) == Some(value),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
filter
|
||||||
|
.and
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|and| and.tags.as_deref())
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.chain(filter.tag.iter())
|
||||||
|
.all(tag_matches)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ReplicationTargetValidationError {
|
pub enum ReplicationTargetValidationError {
|
||||||
RoleWithMultipleDestinations,
|
RoleWithMultipleDestinations,
|
||||||
@@ -704,7 +722,7 @@ impl ReplicationConfigurationExt for ReplicationConfiguration {
|
|||||||
|
|
||||||
if let Some(filter) = &rule.filter {
|
if let Some(filter) = &rule.filter {
|
||||||
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
|
let object_tags = ReplicationTagFilter::decode_tags_to_map(&obj.user_tags);
|
||||||
if filter.test_tags(&object_tags) {
|
if replication_filter_tags_match(filter, &object_tags) {
|
||||||
rules.push(rule.clone());
|
rules.push(rule.clone());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1139,6 +1157,47 @@ mod tests {
|
|||||||
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
|
assert_eq!(validate_replication_config_structure(&structure_config(vec![rule])), Ok(()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actionable_rules_require_every_and_tag_to_match() {
|
||||||
|
let mut rule = replication_rule("rule-1", "arn:target:a");
|
||||||
|
rule.filter = Some(s3s::dto::ReplicationRuleFilter {
|
||||||
|
and: Some(s3s::dto::ReplicationRuleAndOperator {
|
||||||
|
prefix: None,
|
||||||
|
tags: Some(vec![
|
||||||
|
s3s::dto::Tag {
|
||||||
|
key: Some("env".to_string()),
|
||||||
|
value: Some("prod".to_string()),
|
||||||
|
},
|
||||||
|
s3s::dto::Tag {
|
||||||
|
key: Some("tier".to_string()),
|
||||||
|
value: Some("gold".to_string()),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let config = structure_config(vec![rule]);
|
||||||
|
let object = |user_tags: &str| ObjectOpts {
|
||||||
|
name: "object".to_string(),
|
||||||
|
user_tags: user_tags.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(config.filter_target_arns(&object("env=prod")).is_empty());
|
||||||
|
assert_eq!(config.filter_target_arns(&object("env=prod&tier=gold")), vec!["arn:target:a"]);
|
||||||
|
assert!(config.filter_target_arns(&object("")).is_empty());
|
||||||
|
|
||||||
|
let mut malformed = config;
|
||||||
|
malformed.rules[0].filter.as_mut().unwrap().and.as_mut().unwrap().tags = Some(vec![s3s::dto::Tag {
|
||||||
|
key: Some("env".to_string()),
|
||||||
|
value: None,
|
||||||
|
}]);
|
||||||
|
assert!(
|
||||||
|
malformed.filter_target_arns(&object("env=prod")).is_empty(),
|
||||||
|
"a malformed tag filter must fail closed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
|
fn structure_validation_allows_tag_filter_when_delete_marker_replication_disabled() {
|
||||||
let mut rule = replication_rule("rule-1", "arn:target:a");
|
let mut rule = replication_rule("rule-1", "arn:target:a");
|
||||||
|
|||||||
@@ -580,6 +580,30 @@ impl FailStats {
|
|||||||
FailedMetric { count, size }
|
FailedMetric { count, size }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both rolling windows from one walk of the samples. `short` must be the
|
||||||
|
/// narrower window; the walk stops at `long`. Callers that need both (the
|
||||||
|
/// per-node site snapshot) would otherwise scan the deque twice while
|
||||||
|
/// holding the bucket-stats read lock, and the deque is only bounded by
|
||||||
|
/// the one-hour window - an unreachable target under load fills it.
|
||||||
|
pub fn recent_windows(&self, short: Duration, long: Duration) -> (FailedMetric, FailedMetric) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut short_metric = FailedMetric::default();
|
||||||
|
let mut long_metric = FailedMetric::default();
|
||||||
|
for sample in self.recent.iter().rev() {
|
||||||
|
let age = now.duration_since(sample.observed_at);
|
||||||
|
if age > long {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if age <= short {
|
||||||
|
short_metric.count += 1;
|
||||||
|
short_metric.size += sample.size;
|
||||||
|
}
|
||||||
|
long_metric.count += 1;
|
||||||
|
long_metric.size += sample.size;
|
||||||
|
}
|
||||||
|
(short_metric, long_metric)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn merge(&self, other: &FailStats) -> Self {
|
pub fn merge(&self, other: &FailStats) -> Self {
|
||||||
Self {
|
Self {
|
||||||
count: self.count.saturating_add(other.count),
|
count: self.count.saturating_add(other.count),
|
||||||
@@ -912,6 +936,26 @@ mod tests {
|
|||||||
assert_eq!(last_hour.size, 96);
|
assert_eq!(last_hour.size, 96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fail_stats_recent_windows_matches_two_separate_scans() {
|
||||||
|
let mut stats = FailStats::default();
|
||||||
|
stats.add_size(64, None::<&()>);
|
||||||
|
stats.add_size(32, None::<&()>);
|
||||||
|
|
||||||
|
let (minute, hour) = stats.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
||||||
|
let expected_minute = stats.recent_since(Duration::from_secs(60));
|
||||||
|
let expected_hour = stats.recent_since(Duration::from_secs(60 * 60));
|
||||||
|
|
||||||
|
assert_eq!((minute.count, minute.size), (expected_minute.count, expected_minute.size));
|
||||||
|
assert_eq!((hour.count, hour.size), (expected_hour.count, expected_hour.size));
|
||||||
|
assert_eq!(minute.count, 2);
|
||||||
|
assert_eq!(hour.size, 96);
|
||||||
|
|
||||||
|
let empty = FailStats::default();
|
||||||
|
let (minute, hour) = empty.recent_windows(Duration::from_secs(60), Duration::from_secs(60 * 60));
|
||||||
|
assert_eq!((minute.count, minute.size, hour.count, hour.size), (0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fail_stats_saturate_instead_of_wrapping() {
|
fn fail_stats_saturate_instead_of_wrapping() {
|
||||||
let mut stats = FailStats {
|
let mut stats = FailStats {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ s3s = { workspace = true, features = ["minio"] }
|
|||||||
hex-simd.workspace = true
|
hex-simd.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
temp-env = { workspace = true, features = ["async_closure"] }
|
||||||
tokio = { workspace = true, features = ["test-util"] }
|
tokio = { workspace = true, features = ["test-util"] }
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
criterion = { workspace = true, features = ["html_reports"] }
|
criterion = { workspace = true, features = ["html_reports"] }
|
||||||
|
|||||||
@@ -327,6 +327,55 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-side switch for the pre-`1.0.0-alpha.91` nonce layout, in which a whole
|
||||||
|
/// v1 segment reused the part nonce for every block.
|
||||||
|
///
|
||||||
|
/// On by default, because turning it off refuses to decrypt objects written
|
||||||
|
/// before that release. Block zero's derived nonce equals that base nonce, so
|
||||||
|
/// the layout also lets a frame encrypted at index zero authenticate anywhere
|
||||||
|
/// in its segment; the in-segment layout lock catches that as soon as a later
|
||||||
|
/// frame disagrees, but a stream that is nothing but repeats of frame zero has
|
||||||
|
/// no such later frame. A deployment with no pre-alpha.91 objects should set
|
||||||
|
/// this to `false` to remove that surface outright (backlog#2369 P2).
|
||||||
|
///
|
||||||
|
// RUSTFS_COMPAT_TODO(backlog-2369-legacy-nonce-fallback): Remove after the
|
||||||
|
// minimum supported direct-upgrade release and after migration tooling has
|
||||||
|
// rewritten every pre-alpha.91 encrypted object.
|
||||||
|
pub const ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: &str = "RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK";
|
||||||
|
const DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK: bool = true;
|
||||||
|
|
||||||
|
fn legacy_nonce_fallback_enabled() -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
rustfs_utils::get_env_bool(
|
||||||
|
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||||
|
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[cfg(not(test))]
|
||||||
|
{
|
||||||
|
static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*CACHED.get_or_init(|| {
|
||||||
|
rustfs_utils::get_env_bool(
|
||||||
|
ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||||
|
DEFAULT_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nonce layout selected while decoding a legacy v1 segment.
|
||||||
|
///
|
||||||
|
/// A historical writer used one of these layouts consistently for every
|
||||||
|
/// block in a segment. Once a non-zero block identifies that layout, accepting
|
||||||
|
/// another layout would let an attacker replay a block encrypted at index zero.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum V1NonceLayout {
|
||||||
|
Current,
|
||||||
|
LegacyBlock,
|
||||||
|
ReusedPart,
|
||||||
|
}
|
||||||
|
|
||||||
pin_project! {
|
pin_project! {
|
||||||
/// A reader wrapper that decrypts data on the fly using AES-256-GCM.
|
/// A reader wrapper that decrypts data on the fly using AES-256-GCM.
|
||||||
/// This is a demonstration. For production, use a secure and audited crypto library.
|
/// This is a demonstration. For production, use a secure and audited crypto library.
|
||||||
@@ -358,6 +407,8 @@ pin_project! {
|
|||||||
segment_frames: usize,
|
segment_frames: usize,
|
||||||
stream_saw_v2: bool,
|
stream_saw_v2: bool,
|
||||||
segments_completed: usize,
|
segments_completed: usize,
|
||||||
|
v1_nonce_layout: Option<V1NonceLayout>,
|
||||||
|
legacy_nonce_fallback: bool,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +442,8 @@ where
|
|||||||
segment_frames: 0,
|
segment_frames: 0,
|
||||||
stream_saw_v2: false,
|
stream_saw_v2: false,
|
||||||
segments_completed: 0,
|
segments_completed: 0,
|
||||||
|
v1_nonce_layout: None,
|
||||||
|
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +494,8 @@ where
|
|||||||
segment_frames: 0,
|
segment_frames: 0,
|
||||||
stream_saw_v2: false,
|
stream_saw_v2: false,
|
||||||
segments_completed: 0,
|
segments_completed: 0,
|
||||||
|
v1_nonce_layout: None,
|
||||||
|
legacy_nonce_fallback: legacy_nonce_fallback_enabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,6 +602,7 @@ where
|
|||||||
*this.segment_frame_version = None;
|
*this.segment_frame_version = None;
|
||||||
*this.saw_final_frame = false;
|
*this.saw_final_frame = false;
|
||||||
*this.segment_frames = 0;
|
*this.segment_frames = 0;
|
||||||
|
*this.v1_nonce_layout = None;
|
||||||
|
|
||||||
if *this.multipart_mode {
|
if *this.multipart_mode {
|
||||||
let next_part = if *this.current_part_index + 1 < this.multipart_parts.len() {
|
let next_part = if *this.current_part_index + 1 < this.multipart_parts.len() {
|
||||||
@@ -696,26 +752,46 @@ where
|
|||||||
*this.base_nonce
|
*this.base_nonce
|
||||||
};
|
};
|
||||||
let legacy_block_nonce = derive_block_nonce(&legacy_part_nonce, *this.block_index);
|
let legacy_block_nonce = derive_block_nonce(&legacy_part_nonce, *this.block_index);
|
||||||
match this.cipher.decrypt(&nonce, ciphertext) {
|
let legacy_part_nonce =
|
||||||
Ok(plaintext) => plaintext,
|
Nonce::try_from(legacy_part_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
|
||||||
Err(primary_err) => {
|
let legacy_block_nonce =
|
||||||
let legacy_nonce =
|
|
||||||
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
|
Nonce::try_from(legacy_block_nonce.as_slice()).map_err(|_| Error::other("invalid nonce length"))?;
|
||||||
|
let layouts = [
|
||||||
match this.cipher.decrypt(&legacy_nonce, ciphertext) {
|
(V1NonceLayout::Current, &nonce),
|
||||||
Ok(plaintext) => plaintext,
|
(V1NonceLayout::LegacyBlock, &legacy_block_nonce),
|
||||||
Err(_) => {
|
(V1NonceLayout::ReusedPart, &legacy_part_nonce),
|
||||||
// Accept previously written streams that reused the part nonce
|
];
|
||||||
// for every block inside a segment.
|
let selected = if *this.block_index == 0 { None } else { *this.v1_nonce_layout };
|
||||||
let legacy_part_nonce = Nonce::try_from(legacy_part_nonce.as_slice())
|
let mut plaintext = None;
|
||||||
.map_err(|_| Error::other("invalid nonce length"))?;
|
let mut last_error = None;
|
||||||
this.cipher
|
for (layout, candidate_nonce) in layouts {
|
||||||
.decrypt(&legacy_part_nonce, ciphertext)
|
if selected.is_some_and(|expected| expected != layout) {
|
||||||
.map_err(|_| Error::other(format!("decrypt error: {primary_err}")))?
|
continue;
|
||||||
}
|
}
|
||||||
|
if layout == V1NonceLayout::ReusedPart && !*this.legacy_nonce_fallback {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match this.cipher.decrypt(candidate_nonce, ciphertext) {
|
||||||
|
Ok(value) => {
|
||||||
|
plaintext = Some((value, layout));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => last_error = Some(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let (plaintext, layout) = plaintext.ok_or_else(|| {
|
||||||
|
Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!(
|
||||||
|
"decrypt error: {}",
|
||||||
|
last_error.map_or_else(|| "nonce layout rejected".to_string(), |error| error.to_string())
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if *this.block_index > 0 && this.v1_nonce_layout.is_none() {
|
||||||
|
*this.v1_nonce_layout = Some(layout);
|
||||||
}
|
}
|
||||||
|
plaintext
|
||||||
};
|
};
|
||||||
if *this.current_frame_type == FRAME_TYPE_V2_FINAL {
|
if *this.current_frame_type == FRAME_TYPE_V2_FINAL {
|
||||||
*this.saw_final_frame = true;
|
*this.saw_final_frame = true;
|
||||||
@@ -1003,6 +1079,154 @@ mod tests {
|
|||||||
assert_eq!(&decrypted, data);
|
assert_eq!(&decrypted, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encrypts `block_count` full v1 blocks, then overwrites frame one with a
|
||||||
|
/// verbatim copy of frame zero. Every frame is the same length, so the
|
||||||
|
/// stream keeps its original size and the forgery is invisible to any
|
||||||
|
/// length check.
|
||||||
|
async fn v1_stream_with_frame_zero_replayed_at_index_one(key: [u8; 32], nonce: [u8; 12], block_count: usize) -> Vec<u8> {
|
||||||
|
assert!(block_count >= 2, "a replay needs at least two frames");
|
||||||
|
let mut data = Vec::with_capacity(ENCRYPTION_BLOCK_SIZE * block_count);
|
||||||
|
for index in 0..block_count {
|
||||||
|
data.extend(std::iter::repeat_n(0xA1u8.wrapping_add(index as u8 * 17), ENCRYPTION_BLOCK_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut encrypt_reader = EncryptReader::new(Cursor::new(data), key, nonce);
|
||||||
|
let mut encrypted = Vec::new();
|
||||||
|
encrypt_reader.read_to_end(&mut encrypted).await.expect("encrypt v1 frames");
|
||||||
|
|
||||||
|
// Header layout: [type][len:24][crc:32]; `len` counts the payload plus
|
||||||
|
// its own 4-byte CRC field, so the frame occupies 8 + (len - 4) bytes.
|
||||||
|
let declared_len = (encrypted[1] as usize) | ((encrypted[2] as usize) << 8) | ((encrypted[3] as usize) << 16);
|
||||||
|
let frame_len = 8 + declared_len - 4;
|
||||||
|
let replayed_first = encrypted[..frame_len].to_vec();
|
||||||
|
encrypted[frame_len..frame_len * 2].copy_from_slice(&replayed_first);
|
||||||
|
encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn decrypt_reader_rejects_a_replayed_first_v1_frame() {
|
||||||
|
let key = [0x11; 32];
|
||||||
|
let nonce = [0x22; 12];
|
||||||
|
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 3).await;
|
||||||
|
|
||||||
|
let mut decrypt_reader = DecryptReader::new(Cursor::new(encrypted), key, nonce);
|
||||||
|
let error = decrypt_reader
|
||||||
|
.read_to_end(&mut Vec::new())
|
||||||
|
.await
|
||||||
|
.expect_err("a repeated index-zero frame must not authenticate at index one");
|
||||||
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-segment layout lock must not cost compatibility: every legacy v1
|
||||||
|
/// shape the fallback chain exists for still decrypts under the default.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_v1_streams_still_decrypt_under_the_default_fallback() {
|
||||||
|
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
|
||||||
|
assert!(legacy_nonce_fallback_enabled(), "the legacy nonce fallback must stay on by default");
|
||||||
|
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
rand::rng().fill_bytes(&mut key);
|
||||||
|
rand::rng().fill_bytes(&mut nonce);
|
||||||
|
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
|
||||||
|
rand::rng().fill(&mut data[..]);
|
||||||
|
|
||||||
|
// Modern single-part stream.
|
||||||
|
let mut encrypted = Vec::new();
|
||||||
|
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
|
||||||
|
.read_to_end(&mut encrypted)
|
||||||
|
.await
|
||||||
|
.expect("modern v1 stream should encrypt");
|
||||||
|
let mut decrypted = Vec::new();
|
||||||
|
DecryptReader::new(Cursor::new(encrypted), key, nonce)
|
||||||
|
.read_to_end(&mut decrypted)
|
||||||
|
.await
|
||||||
|
.expect("modern v1 stream should decrypt");
|
||||||
|
assert_eq!(decrypted, data);
|
||||||
|
|
||||||
|
// Pre-alpha.91 stream that reused the part nonce for every block.
|
||||||
|
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
|
||||||
|
let mut decrypted = Vec::new();
|
||||||
|
DecryptReader::new(Cursor::new(legacy), key, nonce)
|
||||||
|
.read_to_end(&mut decrypted)
|
||||||
|
.await
|
||||||
|
.expect("a reused-nonce legacy stream should still decrypt");
|
||||||
|
assert_eq!(decrypted, data);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The residual after the layout lock: a stream that is nothing but repeats
|
||||||
|
/// of frame zero has no later frame to disagree with the reused-part
|
||||||
|
/// layout, so only turning the fallback off rejects it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_two_frame_replay_is_closed_only_by_disabling_the_legacy_fallback() {
|
||||||
|
let key = [0x33; 32];
|
||||||
|
let nonce = [0x44; 12];
|
||||||
|
let encrypted = v1_stream_with_frame_zero_replayed_at_index_one(key, nonce, 2).await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, None::<&str>)], async {
|
||||||
|
let mut forged = Vec::new();
|
||||||
|
DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
|
||||||
|
.read_to_end(&mut forged)
|
||||||
|
.await
|
||||||
|
.expect("with the fallback on this forgery is still accepted");
|
||||||
|
assert_eq!(forged.len(), ENCRYPTION_BLOCK_SIZE * 2);
|
||||||
|
assert_eq!(
|
||||||
|
&forged[..ENCRYPTION_BLOCK_SIZE],
|
||||||
|
&forged[ENCRYPTION_BLOCK_SIZE..],
|
||||||
|
"the accepted forgery is frame zero's plaintext twice over"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
|
||||||
|
let error = DecryptReader::new(Cursor::new(encrypted.clone()), key, nonce)
|
||||||
|
.read_to_end(&mut Vec::new())
|
||||||
|
.await
|
||||||
|
.expect_err("with the fallback off the replayed frame must not authenticate");
|
||||||
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning the fallback off removes exactly the third layout: modern v1
|
||||||
|
/// streams keep decrypting, pre-alpha.91 reused-nonce streams stop.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disabling_the_legacy_nonce_fallback_refuses_only_reused_part_nonces() {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
rand::rng().fill_bytes(&mut key);
|
||||||
|
rand::rng().fill_bytes(&mut nonce);
|
||||||
|
let mut data = vec![0u8; ENCRYPTION_BLOCK_SIZE * 3 + 17];
|
||||||
|
rand::rng().fill(&mut data[..]);
|
||||||
|
|
||||||
|
let mut modern = Vec::new();
|
||||||
|
EncryptReader::new(Cursor::new(data.clone()), key, nonce)
|
||||||
|
.read_to_end(&mut modern)
|
||||||
|
.await
|
||||||
|
.expect("modern v1 stream should encrypt");
|
||||||
|
let legacy = encrypt_with_legacy_nonce_reuse(&data, key, nonce);
|
||||||
|
|
||||||
|
temp_env::async_with_vars([(ENV_RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK, Some("false"))], async {
|
||||||
|
assert!(!legacy_nonce_fallback_enabled(), "the switch must be observed");
|
||||||
|
|
||||||
|
let mut decrypted = Vec::new();
|
||||||
|
DecryptReader::new(Cursor::new(modern), key, nonce)
|
||||||
|
.read_to_end(&mut decrypted)
|
||||||
|
.await
|
||||||
|
.expect("modern v1 streams must keep decrypting with the fallback off");
|
||||||
|
assert_eq!(decrypted, data);
|
||||||
|
|
||||||
|
let error = DecryptReader::new(Cursor::new(legacy), key, nonce)
|
||||||
|
.read_to_end(&mut Vec::new())
|
||||||
|
.await
|
||||||
|
.expect_err("the third layout must be gone when the fallback is off");
|
||||||
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_decrypt_reader_only() {
|
async fn test_decrypt_reader_only() {
|
||||||
// Encrypt some data first
|
// Encrypt some data first
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
// 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 bytes::Bytes;
|
||||||
|
use datafusion::object_store::{Error, Result};
|
||||||
|
use futures::{Stream, StreamExt, stream::BoxStream};
|
||||||
|
use transform_stream::AsyncTryStream;
|
||||||
|
|
||||||
|
use crate::SelectError;
|
||||||
|
|
||||||
|
/// Arrow accepts byte-sized CSV controls. Unicode quotes need streaming normalization.
|
||||||
|
pub fn csv_input_requires_normalization(quote: Option<&str>, escape: Option<&str>) -> bool {
|
||||||
|
quote.is_some_and(|quote| quote.len() > 1) || escape.is_some_and(|escape| escape.len() > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CSV syntax independent of request headers, serialization formats, or S3 DTOs.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct CsvSyntax<'a> {
|
||||||
|
pub quote: Option<&'a str>,
|
||||||
|
pub escape: Option<&'a str>,
|
||||||
|
pub field: Option<&'a str>,
|
||||||
|
pub record: Option<&'a str>,
|
||||||
|
pub comment: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum State {
|
||||||
|
FieldStart,
|
||||||
|
Unquoted,
|
||||||
|
Quoted,
|
||||||
|
AfterQuote,
|
||||||
|
Escaped,
|
||||||
|
Comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits ordinary CSV with every field quoted. This avoids reserving a sentinel
|
||||||
|
/// byte that might also appear in a UTF-8 field. Only a partial control token is
|
||||||
|
/// retained between chunks; neither records nor objects are buffered.
|
||||||
|
struct CsvInputNormalizer {
|
||||||
|
quote: Vec<u8>,
|
||||||
|
escape: Vec<u8>,
|
||||||
|
field: Vec<u8>,
|
||||||
|
record: Vec<u8>,
|
||||||
|
comment: Option<u8>,
|
||||||
|
default_records: bool,
|
||||||
|
state: State,
|
||||||
|
record_start: bool,
|
||||||
|
carry: Vec<u8>,
|
||||||
|
token_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CsvInputNormalizer {
|
||||||
|
fn new(csv: &CsvSyntax<'_>) -> Self {
|
||||||
|
let quote = csv
|
||||||
|
.quote
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("\"")
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec();
|
||||||
|
let escape = csv
|
||||||
|
.escape
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("\"")
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec();
|
||||||
|
let field = csv.field.filter(|value| !value.is_empty()).unwrap_or(",").as_bytes().to_vec();
|
||||||
|
let record = csv
|
||||||
|
.record
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("\n")
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec();
|
||||||
|
let token_size = quote.len().max(escape.len()).max(field.len()).max(record.len()).max(2);
|
||||||
|
Self {
|
||||||
|
quote,
|
||||||
|
escape,
|
||||||
|
field,
|
||||||
|
record,
|
||||||
|
comment: csv.comment,
|
||||||
|
default_records: csv.record.is_none(),
|
||||||
|
state: State::FieldStart,
|
||||||
|
record_start: true,
|
||||||
|
carry: Vec::new(),
|
||||||
|
token_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_len(&self, bytes: &[u8]) -> usize {
|
||||||
|
if self.default_records && bytes.starts_with(b"\r\n") {
|
||||||
|
2
|
||||||
|
} else if self.default_records && bytes.starts_with(b"\r") {
|
||||||
|
1
|
||||||
|
} else if bytes.starts_with(&self.record) {
|
||||||
|
self.record.len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_value(output: &mut Vec<u8>, bytes: &[u8]) {
|
||||||
|
for byte in bytes {
|
||||||
|
if *byte == b'"' {
|
||||||
|
output.push(b'"');
|
||||||
|
}
|
||||||
|
output.push(*byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(&mut self, chunk: &[u8], last: bool) -> std::result::Result<Vec<u8>, SelectError> {
|
||||||
|
let mut bytes = std::mem::take(&mut self.carry);
|
||||||
|
bytes.extend_from_slice(chunk);
|
||||||
|
let end = if last {
|
||||||
|
bytes.len()
|
||||||
|
} else {
|
||||||
|
bytes.len().saturating_sub(self.token_size - 1)
|
||||||
|
};
|
||||||
|
let mut output = Vec::with_capacity(bytes.len());
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos < end {
|
||||||
|
let rest = &bytes[pos..];
|
||||||
|
let record_len = self.record_len(rest);
|
||||||
|
let field = rest.starts_with(&self.field) && self.field.len() > record_len;
|
||||||
|
match self.state {
|
||||||
|
State::Comment => {
|
||||||
|
if record_len > 0 {
|
||||||
|
self.state = State::FieldStart;
|
||||||
|
pos += record_len;
|
||||||
|
} else {
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::Escaped => {
|
||||||
|
if record_len > 0 {
|
||||||
|
return Err(SelectError::CsvParsingError);
|
||||||
|
}
|
||||||
|
Self::push_value(&mut output, &rest[..1]);
|
||||||
|
self.state = State::Quoted;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
State::Quoted if rest.starts_with(&self.quote) => {
|
||||||
|
self.state = State::AfterQuote;
|
||||||
|
pos += self.quote.len();
|
||||||
|
}
|
||||||
|
State::Quoted if rest.starts_with(&self.escape) => {
|
||||||
|
self.state = State::Escaped;
|
||||||
|
pos += self.escape.len();
|
||||||
|
}
|
||||||
|
State::Quoted => {
|
||||||
|
if record_len > 0 {
|
||||||
|
return Err(SelectError::CsvParsingError);
|
||||||
|
}
|
||||||
|
Self::push_value(&mut output, &rest[..1]);
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
State::AfterQuote if rest.starts_with(&self.quote) => {
|
||||||
|
Self::push_value(&mut output, &self.quote);
|
||||||
|
self.state = State::Quoted;
|
||||||
|
pos += self.quote.len();
|
||||||
|
}
|
||||||
|
State::FieldStart if self.record_start && self.comment == Some(rest[0]) => {
|
||||||
|
self.state = State::Comment;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
State::FieldStart if rest.starts_with(&self.quote) => {
|
||||||
|
output.push(b'"');
|
||||||
|
self.state = State::Quoted;
|
||||||
|
self.record_start = false;
|
||||||
|
pos += self.quote.len();
|
||||||
|
}
|
||||||
|
_ if field || record_len > 0 => {
|
||||||
|
if self.state == State::FieldStart {
|
||||||
|
if field || !self.record_start {
|
||||||
|
output.extend_from_slice(b"\"\"");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.push(b'"');
|
||||||
|
}
|
||||||
|
output.push(if field { b',' } else { b'\n' });
|
||||||
|
self.state = State::FieldStart;
|
||||||
|
self.record_start = !field;
|
||||||
|
pos += if field { self.field.len() } else { record_len };
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if self.state == State::FieldStart {
|
||||||
|
output.push(b'"');
|
||||||
|
}
|
||||||
|
self.state = State::Unquoted;
|
||||||
|
self.record_start = false;
|
||||||
|
Self::push_value(&mut output, &rest[..1]);
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.carry.extend_from_slice(&bytes[pos..]);
|
||||||
|
if last {
|
||||||
|
match self.state {
|
||||||
|
State::Quoted | State::Escaped => return Err(SelectError::CsvParsingError),
|
||||||
|
State::Unquoted | State::AfterQuote => output.push(b'"'),
|
||||||
|
State::FieldStart if !self.record_start => output.extend_from_slice(b"\"\""),
|
||||||
|
State::FieldStart | State::Comment => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn normalize_chunks(csv: &CsvSyntax<'_>, input: &[u8], chunk_size: usize) -> Vec<u8> {
|
||||||
|
let mut normalizer = CsvInputNormalizer::new(csv);
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for chunk in input.chunks(chunk_size) {
|
||||||
|
output.extend(normalizer.convert(chunk, false).expect("normalize complete CSV input"));
|
||||||
|
assert!(normalizer.carry.len() < normalizer.token_size, "only a partial token may be retained");
|
||||||
|
}
|
||||||
|
output.extend(normalizer.convert(&[], true).expect("finish complete CSV input"));
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_csv_quotes_preserve_values_at_every_chunk_boundary() {
|
||||||
|
let cases = [
|
||||||
|
("ع", "\"", "عcol1ع,عcol2ع,عcol3ع\n", "\"col1\",\"col2\",\"col3\"\n"),
|
||||||
|
("ع", "\"", "\"left\",tail\n", "\"\"\"left\"\"\",\"tail\"\n"),
|
||||||
|
("ع", "\"", "عA,Bع,plain\n", "\"A,B\",\"plain\"\n"),
|
||||||
|
("ع", "\"", "عAععBع,tail\n", "\"AعB\",\"tail\"\n"),
|
||||||
|
("ع", "\"", "عA\"عBع,tail\n", "\"AعB\",\"tail\"\n"),
|
||||||
|
("ع", "\\", "عA\\\"Bع,tail\n", "\"A\"\"B\",\"tail\"\n"),
|
||||||
|
("\"", "界", "\"A界\"B\",\"C\"\n", "\"A\"\"B\",\"C\"\n"),
|
||||||
|
("🦀", "🦀", "🦀A🦀🦀B🦀,C\n", "\"A🦀B\",\"C\"\n"),
|
||||||
|
("ع", "\"", "a\0b,عc\0dع\n", "\"a\0b\",\"c\0d\"\n"),
|
||||||
|
("ع", "\"", "AعB,tail\n", "\"AعB\",\"tail\"\n"),
|
||||||
|
("ع", "\"", "عaعsuffix,tail\n", "\"asuffix\",\"tail\"\n"),
|
||||||
|
("ع", "\"", ",\n", "\"\",\"\"\n"),
|
||||||
|
("ع", "\"", "a,", "\"a\",\"\""),
|
||||||
|
("ع", "\"", "عع", "\"\""),
|
||||||
|
("ع", "\"", "\n", "\n"),
|
||||||
|
("ع", "\"", "", ""),
|
||||||
|
];
|
||||||
|
for (quote, escape, input, expected) in cases {
|
||||||
|
let csv = CsvSyntax {
|
||||||
|
quote: Some(quote),
|
||||||
|
escape: Some(escape),
|
||||||
|
record: Some("\n"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for chunk_size in 1..=input.len().max(1) {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_chunks(&csv, input.as_bytes(), chunk_size),
|
||||||
|
expected.as_bytes(),
|
||||||
|
"input={input:?}, chunk_size={chunk_size}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_csv_quotes_keep_custom_delimiters_and_comments_out_of_values() {
|
||||||
|
let csv = CsvSyntax {
|
||||||
|
quote: Some("ع"),
|
||||||
|
escape: Some("\\"),
|
||||||
|
field: Some("界"),
|
||||||
|
record: Some("^Y"),
|
||||||
|
comment: Some(b'#'),
|
||||||
|
};
|
||||||
|
let input = "#skipع界^Yعa界bع界\"literal\"^Yعline\nbreakع界end^Y";
|
||||||
|
let expected = "\"a界b\",\"\"\"literal\"\"\"\n\"line\nbreak\",\"end\"\n";
|
||||||
|
for chunk_size in 1..=input.len() {
|
||||||
|
assert_eq!(normalize_chunks(&csv, input.as_bytes(), chunk_size), expected.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_csv_quotes_reject_unterminated_fields_and_quoted_record_delimiters() {
|
||||||
|
for input in ["عunfinished", "عescape\\", "عline\nbreakع\n", "عline\\\nbreakع\n"] {
|
||||||
|
let csv = CsvSyntax {
|
||||||
|
quote: Some("ع"),
|
||||||
|
escape: Some("\\"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut normalizer = CsvInputNormalizer::new(&csv);
|
||||||
|
assert_eq!(normalizer.convert(input.as_bytes(), true), Err(SelectError::CsvParsingError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_csv_quotes_preserve_omitted_syntax_defaults() {
|
||||||
|
assert!(!csv_input_requires_normalization(None, None));
|
||||||
|
assert!(!csv_input_requires_normalization(Some("\""), Some("\\")));
|
||||||
|
assert!(csv_input_requires_normalization(Some("ع"), None));
|
||||||
|
assert!(csv_input_requires_normalization(None, Some("界")));
|
||||||
|
|
||||||
|
let quote_only = CsvSyntax {
|
||||||
|
quote: Some("ع"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
normalize_chunks("e_only, "عA\"عBع,tail\r\n".as_bytes(), 1),
|
||||||
|
"\"AعB\",\"tail\"\n".as_bytes()
|
||||||
|
);
|
||||||
|
let escape_only = CsvSyntax {
|
||||||
|
escape: Some("界"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
normalize_chunks(&escape_only, "\"A界\"B\",tail\r\n".as_bytes(), 1),
|
||||||
|
b"\"A\"\"B\",\"tail\"\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_csv_quotes_stream_large_fields_without_retaining_records() {
|
||||||
|
let csv = CsvSyntax {
|
||||||
|
quote: Some("ع"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut normalizer = CsvInputNormalizer::new(&csv);
|
||||||
|
let chunk = vec![b'x'; 64 * 1024];
|
||||||
|
let mut output_len = normalizer.convert("ع".as_bytes(), false).expect("opening quote").len();
|
||||||
|
for _ in 0..64 {
|
||||||
|
let output = normalizer.convert(&chunk, false).expect("stream field chunk");
|
||||||
|
assert!(output.len() >= chunk.len() - 3, "field data must be emitted before its closing quote");
|
||||||
|
assert!(normalizer.carry.len() < 4);
|
||||||
|
output_len += output.len();
|
||||||
|
}
|
||||||
|
output_len += normalizer.convert("ع\n".as_bytes(), true).expect("close field").len();
|
||||||
|
assert_eq!(output_len, chunk.len() * 64 + 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_csv_stream<S>(stream: S, csv: &CsvSyntax<'_>) -> BoxStream<'static, Result<Bytes>>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<Bytes>> + Send + 'static,
|
||||||
|
{
|
||||||
|
let mut normalizer = CsvInputNormalizer::new(csv);
|
||||||
|
AsyncTryStream::<Bytes, Error, _>::new(|mut y| async move {
|
||||||
|
futures::pin_mut!(stream);
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let converted = normalizer.convert(&chunk?, false).map_err(|source| Error::Generic {
|
||||||
|
store: "EcObjectStore",
|
||||||
|
source: Box::new(source),
|
||||||
|
})?;
|
||||||
|
if !converted.is_empty() {
|
||||||
|
y.yield_ok(Bytes::from(converted)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let converted = normalizer.convert(&[], true).map_err(|source| Error::Generic {
|
||||||
|
store: "EcObjectStore",
|
||||||
|
source: Box::new(source),
|
||||||
|
})?;
|
||||||
|
if !converted.is_empty() {
|
||||||
|
y.yield_ok(Bytes::from(converted)).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod stream_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DropProbe(Arc<AtomicBool>);
|
||||||
|
|
||||||
|
impl Drop for DropProbe {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unicode_csv_quotes_drop_the_source_without_reading_ahead() {
|
||||||
|
let polls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let source =
|
||||||
|
futures::stream::unfold((DropProbe(Arc::clone(&dropped)), Arc::clone(&polls)), |(guard, polls)| async move {
|
||||||
|
polls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Some((Ok(Bytes::from_static("عvalueع\n".as_bytes())), (guard, polls)))
|
||||||
|
});
|
||||||
|
let csv = CsvSyntax {
|
||||||
|
quote: Some("ع"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut stream = normalize_csv_stream(source, &csv);
|
||||||
|
assert!(!stream.next().await.expect("first output").expect("valid CSV").is_empty());
|
||||||
|
assert_eq!(polls.load(Ordering::SeqCst), 1);
|
||||||
|
drop(stream);
|
||||||
|
assert!(dropped.load(Ordering::SeqCst), "cancellation must release the source reader");
|
||||||
|
assert_eq!(polls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unicode_csv_quotes_preserve_source_errors_after_partial_output() {
|
||||||
|
let source = futures::stream::iter([
|
||||||
|
Ok(Bytes::from_static("عvalueع\n".as_bytes())),
|
||||||
|
Err(Error::Generic {
|
||||||
|
store: "fixture",
|
||||||
|
source: std::io::Error::other("source read failed").into(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let csv = CsvSyntax {
|
||||||
|
quote: Some("ع"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut stream = normalize_csv_stream(source, &csv);
|
||||||
|
assert!(!stream.next().await.expect("partial output").expect("valid prefix").is_empty());
|
||||||
|
let error = stream
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.expect("source failure must remain visible")
|
||||||
|
.expect_err("must not return a successful tail");
|
||||||
|
assert!(error.to_string().contains("source read failed"));
|
||||||
|
assert!(stream.next().await.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,12 +23,14 @@ use datafusion::{
|
|||||||
use std::{error::Error as StdError, fmt::Display};
|
use std::{error::Error as StdError, fmt::Display};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
mod csv_input;
|
||||||
mod input_stream;
|
mod input_stream;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
pub mod object_store;
|
pub mod object_store;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
mod storage_api;
|
mod storage_api;
|
||||||
|
pub use csv_input::csv_input_requires_normalization;
|
||||||
pub use metrics::{SelectInputMetrics, SelectInputMetricsSnapshot};
|
pub use metrics::{SelectInputMetrics, SelectInputMetricsSnapshot};
|
||||||
pub use storage_api::SelectObjectSnapshot;
|
pub use storage_api::SelectObjectSnapshot;
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ use tokio::{io::AsyncReadExt, sync::OnceCell};
|
|||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use transform_stream::AsyncTryStream;
|
use transform_stream::AsyncTryStream;
|
||||||
|
|
||||||
|
use crate::csv_input::{CsvSyntax, csv_input_requires_normalization, normalize_csv_stream};
|
||||||
use crate::storage_api::object_store::HTTPRangeSpec;
|
use crate::storage_api::object_store::HTTPRangeSpec;
|
||||||
|
|
||||||
mod json_document;
|
mod json_document;
|
||||||
@@ -345,6 +346,29 @@ impl EcObjectStore {
|
|||||||
(self.need_convert || (delimiter.len() == 2 && delimiter != NORMALIZED_RECORD_DELIMITER)).then_some(delimiter)
|
(self.need_convert || (delimiter.len() == 2 && delimiter != NORMALIZED_RECORD_DELIMITER)).then_some(delimiter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn convert_csv_stream<S>(&self, stream: S) -> BoxStream<'static, Result<Bytes>>
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<Bytes>> + Send + 'static,
|
||||||
|
{
|
||||||
|
if let Some(csv) = self.input.request.input_serialization.csv.as_ref()
|
||||||
|
&& csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
|
||||||
|
{
|
||||||
|
let syntax = CsvSyntax {
|
||||||
|
quote: csv.quote_character.as_deref(),
|
||||||
|
escape: csv.quote_escape_character.as_deref(),
|
||||||
|
field: csv.field_delimiter.as_deref(),
|
||||||
|
record: csv.record_delimiter.as_deref(),
|
||||||
|
comment: csv.comments.as_ref().and_then(|comment| comment.as_bytes().first().copied()),
|
||||||
|
};
|
||||||
|
return normalize_csv_stream(stream, &syntax);
|
||||||
|
}
|
||||||
|
convert_csv_delimiter_stream(
|
||||||
|
stream,
|
||||||
|
self.record_delimiter_for_conversion(),
|
||||||
|
self.need_convert.then(|| self.delimiter.clone()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn csv_has_header(&self) -> bool {
|
fn csv_has_header(&self) -> bool {
|
||||||
self.input
|
self.input
|
||||||
.request
|
.request
|
||||||
@@ -820,7 +844,6 @@ impl ObjectStore for EcObjectStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let record_delimiter = self.record_delimiter_for_conversion();
|
|
||||||
let needs_scan_context = options.range.is_none() && has_effective_request_range;
|
let needs_scan_context = options.range.is_none() && has_effective_request_range;
|
||||||
let scan_context = if needs_scan_context {
|
let scan_context = if needs_scan_context {
|
||||||
if let Some(scan_range) = self.scan_range(original_size)? {
|
if let Some(scan_range) = self.scan_range(original_size)? {
|
||||||
@@ -883,8 +906,7 @@ impl ObjectStore for EcObjectStore {
|
|||||||
max_processed_bytes,
|
max_processed_bytes,
|
||||||
query_guard,
|
query_guard,
|
||||||
)?;
|
)?;
|
||||||
let stream =
|
let stream = self.convert_csv_stream(stream);
|
||||||
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
|
|
||||||
GetResultPayload::Stream(stream)
|
GetResultPayload::Stream(stream)
|
||||||
}
|
}
|
||||||
} else if options.range.is_some() {
|
} else if options.range.is_some() {
|
||||||
@@ -937,8 +959,7 @@ impl ObjectStore for EcObjectStore {
|
|||||||
} else {
|
} else {
|
||||||
stream
|
stream
|
||||||
};
|
};
|
||||||
let stream =
|
let stream = self.convert_csv_stream(stream);
|
||||||
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
|
|
||||||
GetResultPayload::Stream(stream)
|
GetResultPayload::Stream(stream)
|
||||||
} else {
|
} else {
|
||||||
let stream_size = usize::try_from(original_size).map_err(|err| o_Error::Generic {
|
let stream_size = usize::try_from(original_size).map_err(|err| o_Error::Generic {
|
||||||
@@ -948,8 +969,7 @@ impl ObjectStore for EcObjectStore {
|
|||||||
let stream = bytes_stream(ReaderStream::with_capacity(reader.stream, SELECT_DEFAULT_READ_BUFFER_SIZE), stream_size);
|
let stream = bytes_stream(ReaderStream::with_capacity(reader.stream, SELECT_DEFAULT_READ_BUFFER_SIZE), stream_size);
|
||||||
if meter_input {
|
if meter_input {
|
||||||
let stream = meter_uncompressed_input_stream(stream, Arc::clone(&self.input_metrics));
|
let stream = meter_uncompressed_input_stream(stream, Arc::clone(&self.input_metrics));
|
||||||
let stream =
|
let stream = self.convert_csv_stream(stream);
|
||||||
convert_csv_delimiter_stream(stream, record_delimiter, self.need_convert.then(|| self.delimiter.clone()));
|
|
||||||
GetResultPayload::Stream(stream)
|
GetResultPayload::Stream(stream)
|
||||||
} else {
|
} else {
|
||||||
GetResultPayload::Stream(stream.boxed())
|
GetResultPayload::Stream(stream.boxed())
|
||||||
@@ -2866,6 +2886,88 @@ mod test {
|
|||||||
assert_eq!(input_metrics.snapshot().bytes_processed, 2);
|
assert_eq!(input_metrics.snapshot().bytes_processed, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unicode_csv_quotes_preserve_raw_offsets_and_metrics() {
|
||||||
|
const BUCKET: &str = "s3select-unicode-csv-stream";
|
||||||
|
const HEADER: &str = "عnameع,عkindع\n";
|
||||||
|
const SKIP: &str = "عskipع,عzeroع\n";
|
||||||
|
const ROW: &str = "عA,Bع,عAععBع\n";
|
||||||
|
let data = format!("{HEADER}{SKIP}{ROW}");
|
||||||
|
let env = crate::storage_api::select_test_ecstore_env().await;
|
||||||
|
env.make_bucket(BUCKET, false).await;
|
||||||
|
for (object, compression, range_offset) in [
|
||||||
|
("plain.csv", None, None),
|
||||||
|
("range.csv", None, Some(0)),
|
||||||
|
("range-mid-character.csv", None, Some(1)),
|
||||||
|
("gzip.csv", Some(CompressionFormat::Gzip), None),
|
||||||
|
("bzip.csv", Some(CompressionFormat::Bzip2), None),
|
||||||
|
] {
|
||||||
|
let bytes = match compression {
|
||||||
|
Some(format) => encode_compressed_fixture(format, data.as_bytes()).await,
|
||||||
|
None => data.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
let raw_size = bytes.len();
|
||||||
|
let mut reader = SelectPutObjReader::from_vec(bytes);
|
||||||
|
env.ecstore
|
||||||
|
.put_object(BUCKET, object, &mut reader, &Default::default())
|
||||||
|
.await
|
||||||
|
.expect("write Unicode CSV fixture");
|
||||||
|
let mut input = (*csv_input(BUCKET, object)).clone();
|
||||||
|
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
|
||||||
|
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::USE));
|
||||||
|
csv.quote_character = Some("ع".to_owned());
|
||||||
|
csv.quote_escape_character = Some("\\".to_owned());
|
||||||
|
csv.record_delimiter = Some("\n".to_owned());
|
||||||
|
input.request.input_serialization.compression_type = compression.map(|format| {
|
||||||
|
CompressionType::from_static(match format {
|
||||||
|
CompressionFormat::Gzip => CompressionType::GZIP,
|
||||||
|
CompressionFormat::Bzip2 => CompressionType::BZIP2,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let start = HEADER.len() + SKIP.len();
|
||||||
|
if let Some(range_offset) = range_offset {
|
||||||
|
let offset = i64::try_from(start + range_offset).expect("fixture offset");
|
||||||
|
input.request.scan_range = Some(ScanRange {
|
||||||
|
start: Some(offset),
|
||||||
|
end: Some(offset),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let metrics = Arc::new(SelectInputMetrics::default());
|
||||||
|
let store = EcObjectStore::build_with_snapshot(
|
||||||
|
Arc::new(input),
|
||||||
|
Arc::new(GreedyMemoryPool::new(1024 * 1024)),
|
||||||
|
None,
|
||||||
|
Arc::clone(&metrics),
|
||||||
|
prepare_test_snapshot(BUCKET, object).await,
|
||||||
|
JsonSource::default(),
|
||||||
|
)
|
||||||
|
.expect("snapshot store");
|
||||||
|
let result = store
|
||||||
|
.get_opts(&Path::from(object), GetOptions::default())
|
||||||
|
.await
|
||||||
|
.expect("open Unicode CSV stream");
|
||||||
|
let GetResultPayload::Stream(stream) = result.payload else { panic!("CSV must remain streaming") };
|
||||||
|
let output = stream.try_collect::<Vec<_>>().await.expect("normalize CSV stream").concat();
|
||||||
|
let expected = match range_offset {
|
||||||
|
Some(0) => "\"name\",\"kind\"\n\"A,B\",\"AعB\"\n",
|
||||||
|
Some(_) => "\"name\",\"kind\"\n",
|
||||||
|
None => "\"name\",\"kind\"\n\"skip\",\"zero\"\n\"A,B\",\"AعB\"\n",
|
||||||
|
};
|
||||||
|
assert_eq!(output, expected.as_bytes(), "object={object}");
|
||||||
|
let measured = metrics.snapshot();
|
||||||
|
if let Some(range_offset) = range_offset {
|
||||||
|
// The range reader includes one byte of delimiter context and a
|
||||||
|
// separate header read; offsets always refer to the original CSV.
|
||||||
|
let processed = u64::try_from(ROW.len() + 1 - range_offset + HEADER.len()).expect("raw range length");
|
||||||
|
assert_eq!(measured.bytes_scanned, processed);
|
||||||
|
assert_eq!(measured.bytes_processed, processed);
|
||||||
|
} else {
|
||||||
|
assert_eq!(measured.bytes_scanned, u64::try_from(raw_size).expect("raw length"));
|
||||||
|
assert_eq!(measured.bytes_processed, u64::try_from(data.len()).expect("decoded length"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn compressed_object_uses_one_full_stream_and_rejects_internal_ranges() {
|
async fn compressed_object_uses_one_full_stream_and_rejects_internal_ranges() {
|
||||||
const BUCKET: &str = "s3select-compressed-object";
|
const BUCKET: &str = "s3select-compressed-object";
|
||||||
|
|||||||
@@ -456,7 +456,12 @@ impl SessionCtxFactory {
|
|||||||
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
|
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
|
||||||
let metered_input_requires_single_file_scan =
|
let metered_input_requires_single_file_scan =
|
||||||
input_metrics.is_some() && context.input.request.input_serialization.parquet.is_none();
|
input_metrics.is_some() && context.input.request.input_serialization.parquet.is_none();
|
||||||
let config = if custom_two_byte_record_delimiter
|
let normalized_csv_requires_single_file_scan =
|
||||||
|
context.input.request.input_serialization.csv.as_ref().is_some_and(|csv| {
|
||||||
|
crate::csv_input_requires_normalization(csv.quote_character.as_deref(), csv.quote_escape_character.as_deref())
|
||||||
|
});
|
||||||
|
let config = if normalized_csv_requires_single_file_scan
|
||||||
|
|| custom_two_byte_record_delimiter
|
||||||
|| scan_range_requires_single_file_scan
|
|| scan_range_requires_single_file_scan
|
||||||
|| json_document_requires_single_file_scan
|
|| json_document_requires_single_file_scan
|
||||||
|| compressed_input_requires_single_file_scan
|
|| compressed_input_requires_single_file_scan
|
||||||
@@ -906,6 +911,25 @@ mod tests {
|
|||||||
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
|
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unicode_csv_quotes_disable_file_scan_repartition() {
|
||||||
|
let mut context = test_context();
|
||||||
|
Arc::get_mut(&mut context.input)
|
||||||
|
.expect("unique context")
|
||||||
|
.request
|
||||||
|
.input_serialization
|
||||||
|
.csv
|
||||||
|
.as_mut()
|
||||||
|
.expect("CSV input")
|
||||||
|
.quote_character = Some("ع".to_owned());
|
||||||
|
let session = SessionCtxFactory::new(true)
|
||||||
|
.with_target_partitions(4)
|
||||||
|
.create_session_ctx(&context)
|
||||||
|
.await
|
||||||
|
.expect("Unicode CSV session");
|
||||||
|
assert!(!session.inner().config().options().optimizer.repartition_file_scans);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn two_byte_csv_record_delimiter_disables_file_scan_repartition() {
|
async fn two_byte_csv_record_delimiter_disables_file_scan_repartition() {
|
||||||
let mut context = test_context();
|
let mut context = test_context();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ use rustfs_s3select_api::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use s3s::dto::{CompressionType, FileHeaderInfo, JSONType, SelectObjectContentInput};
|
use s3s::dto::{FileHeaderInfo, JSONType, SelectObjectContentInput};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::Semaphore,
|
sync::Semaphore,
|
||||||
@@ -430,13 +430,6 @@ impl SimpleQueryDispatcher {
|
|||||||
|
|
||||||
let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
|
let path = format!("s3://{}/{}", self.input.bucket, self.input.key);
|
||||||
let table_path = ListingTableUrl::parse(path)?;
|
let table_path = ListingTableUrl::parse(path)?;
|
||||||
let compressed_input = self
|
|
||||||
.input
|
|
||||||
.request
|
|
||||||
.input_serialization
|
|
||||||
.compression_type
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|compression| compression.as_str() != CompressionType::NONE);
|
|
||||||
let (listing_options, need_rename_volume_name, need_ignore_volume_name) =
|
let (listing_options, need_rename_volume_name, need_ignore_volume_name) =
|
||||||
if let Some(csv) = self.input.request.input_serialization.csv.as_ref() {
|
if let Some(csv) = self.input.request.input_serialization.csv.as_ref() {
|
||||||
let mut need_rename_volume_name = false;
|
let mut need_rename_volume_name = false;
|
||||||
@@ -485,28 +478,27 @@ impl SimpleQueryDispatcher {
|
|||||||
if let Some(quote) = csv.quote_character.as_ref() {
|
if let Some(quote) = csv.quote_character.as_ref() {
|
||||||
file_format = file_format.with_quote(quote.as_bytes().first().copied().unwrap_or_default());
|
file_format = file_format.with_quote(quote.as_bytes().first().copied().unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
if rustfs_s3select_api::csv_input_requires_normalization(
|
||||||
|
csv.quote_character.as_deref(),
|
||||||
|
csv.quote_escape_character.as_deref(),
|
||||||
|
) {
|
||||||
|
file_format = file_format
|
||||||
|
.with_quote(b'"')
|
||||||
|
.with_escape(None)
|
||||||
|
.with_delimiter(b',')
|
||||||
|
.with_terminator(Some(b'\n'))
|
||||||
|
.with_comment(None)
|
||||||
|
.with_newlines_in_values(true);
|
||||||
|
}
|
||||||
(
|
(
|
||||||
ListingOptions::new(Arc::new(file_format)).with_file_extension(if compressed_input {
|
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
|
||||||
EXACT_OBJECT_FILE_EXTENSION
|
|
||||||
} else {
|
|
||||||
".csv"
|
|
||||||
}),
|
|
||||||
need_rename_volume_name,
|
need_rename_volume_name,
|
||||||
need_ignore_volume_name,
|
need_ignore_volume_name,
|
||||||
)
|
)
|
||||||
} else if self.input.request.input_serialization.json.is_some() {
|
} else if self.input.request.input_serialization.json.is_some() {
|
||||||
let file_format = JsonFormat::default();
|
let file_format = JsonFormat::default();
|
||||||
let file_extension = if compressed_input {
|
|
||||||
EXACT_OBJECT_FILE_EXTENSION.to_string()
|
|
||||||
} else {
|
|
||||||
std::path::Path::new(&self.input.key)
|
|
||||||
.extension()
|
|
||||||
.and_then(|extension| extension.to_str())
|
|
||||||
.map(|extension| format!(".{extension}"))
|
|
||||||
.unwrap_or_else(|| ".json".to_string())
|
|
||||||
};
|
|
||||||
(
|
(
|
||||||
ListingOptions::new(Arc::new(file_format)).with_file_extension(file_extension),
|
ListingOptions::new(Arc::new(file_format)).with_file_extension(EXACT_OBJECT_FILE_EXTENSION),
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -1531,6 +1523,130 @@ mod tests {
|
|||||||
assert_eq!(error.select_error(), SelectError::InvalidDataSource);
|
assert_eq!(error.select_error(), SelectError::InvalidDataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unicode_csv_quotes_reach_arrow_without_changing_field_values() {
|
||||||
|
let cases = [
|
||||||
|
("ع", "\"", ",", "\n", "عcol1ع,عcol2ع,عcol3ع\n", vec![vec!["col1", "col2", "col3"]]),
|
||||||
|
(
|
||||||
|
"ع",
|
||||||
|
"\\",
|
||||||
|
",",
|
||||||
|
"\n",
|
||||||
|
"\"literal\",عA\\\"Bع,عAععBع\n",
|
||||||
|
vec![vec!["\"literal\"", "A\"B", "AعB"]],
|
||||||
|
),
|
||||||
|
("\"", "界", ",", "\n", "\"A界\"B\",🦀\n", vec![vec!["A\"B", "🦀"]]),
|
||||||
|
("ع", "\\", "界", "^Y", "عa界bع界عline\nbreakع^Y", vec![vec!["a界b", "line\nbreak"]]),
|
||||||
|
];
|
||||||
|
let env = snapshot_test_env().await;
|
||||||
|
for (index, (quote, escape, field, record, data, expected)) in cases.into_iter().enumerate() {
|
||||||
|
let mut input = test_input();
|
||||||
|
input.bucket = format!("select-unicode-quotes-{index}");
|
||||||
|
input.key = "records".to_owned();
|
||||||
|
let csv = input.request.input_serialization.csv.as_mut().expect("CSV input");
|
||||||
|
csv.file_header_info = Some(FileHeaderInfo::from_static(FileHeaderInfo::NONE));
|
||||||
|
csv.quote_character = Some(quote.to_owned());
|
||||||
|
csv.quote_escape_character = Some(escape.to_owned());
|
||||||
|
csv.field_delimiter = Some(field.to_owned());
|
||||||
|
csv.record_delimiter = Some(record.to_owned());
|
||||||
|
env.make_bucket(&input.bucket, false).await;
|
||||||
|
env.put_object_bytes(&input.bucket, &input.key, data.as_bytes().to_vec())
|
||||||
|
.await;
|
||||||
|
let snapshot = env.prepare_select_object_snapshot(&input.bucket, &input.key).await;
|
||||||
|
let input = Arc::new(input);
|
||||||
|
let dispatcher = production_dispatcher(Arc::clone(&input));
|
||||||
|
let query = Query::new_with_snapshot(QueryContext { input }, "SELECT * FROM S3Object".to_owned(), snapshot);
|
||||||
|
let output = dispatcher.execute_query(&query).await.expect("execute Unicode CSV query");
|
||||||
|
let mut stream = output.into_record_batch_stream().expect("record stream");
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
while let Some(batch) = stream.next().await {
|
||||||
|
let batch = batch.expect("Arrow must receive valid UTF-8 fields");
|
||||||
|
for row in 0..batch.num_rows() {
|
||||||
|
rows.push(
|
||||||
|
batch
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|column| {
|
||||||
|
column
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<StringArray>()
|
||||||
|
.expect("CSV string column")
|
||||||
|
.value(row)
|
||||||
|
.to_owned()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(rows, expected, "fixture={index}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn select_uses_input_serialization_independently_of_object_extension() {
|
||||||
|
for (key, json) in [
|
||||||
|
("records", false),
|
||||||
|
("records.bin", false),
|
||||||
|
("records", true),
|
||||||
|
("records.csv", true),
|
||||||
|
] {
|
||||||
|
let mut input = test_input();
|
||||||
|
input.key = key.to_owned();
|
||||||
|
let data = if json {
|
||||||
|
input.request.input_serialization.csv = None;
|
||||||
|
input.request.input_serialization.json = Some(s3s::dto::JSONInput {
|
||||||
|
type_: Some(JSONType::from_static(JSONType::LINES)),
|
||||||
|
});
|
||||||
|
b"{\"value\":\"selected\"}\n".as_slice()
|
||||||
|
} else {
|
||||||
|
b"value\nselected\n".as_slice()
|
||||||
|
};
|
||||||
|
let input = Arc::new(input);
|
||||||
|
let optimizer = Arc::new(CascadeOptimizerBuilder::default().build());
|
||||||
|
let dispatcher = test_dispatcher_for_input(
|
||||||
|
Arc::clone(&input),
|
||||||
|
Arc::new(Semaphore::new(1)),
|
||||||
|
Duration::from_secs(30),
|
||||||
|
Arc::new(SqlQueryExecutionFactory::new(optimizer, Arc::new(LocalScheduler {}))),
|
||||||
|
);
|
||||||
|
let query = Query::new(QueryContext { input }, "SELECT * FROM S3Object".to_owned());
|
||||||
|
let machine = dispatcher.build_query_state_machine(query).await.expect("build query state");
|
||||||
|
let store_url = ObjectStoreUrl::parse("s3://test-bucket").expect("test store URL");
|
||||||
|
let store = machine
|
||||||
|
.session
|
||||||
|
.inner()
|
||||||
|
.runtime_env()
|
||||||
|
.object_store(&store_url)
|
||||||
|
.expect("test store");
|
||||||
|
store.put(&Path::from(key), data.into()).await.expect("write selected object");
|
||||||
|
store
|
||||||
|
.put(&Path::from(format!("{key}.other")), b"unrelated\nwrong\n".as_slice().into())
|
||||||
|
.await
|
||||||
|
.expect("write neighboring object");
|
||||||
|
let plan = dispatcher
|
||||||
|
.build_logical_plan(Arc::clone(&machine))
|
||||||
|
.await
|
||||||
|
.expect("infer schema without an extension filter")
|
||||||
|
.expect("select plan");
|
||||||
|
let output = dispatcher
|
||||||
|
.execute_logical_plan(plan, machine)
|
||||||
|
.await
|
||||||
|
.expect("execute selected object");
|
||||||
|
let mut stream = output.into_record_batch_stream().expect("record stream");
|
||||||
|
let mut values = Vec::new();
|
||||||
|
while let Some(batch) = stream.next().await {
|
||||||
|
let batch = batch.expect("selected batch");
|
||||||
|
let column = batch
|
||||||
|
.column(0)
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<datafusion::arrow::array::StringArray>()
|
||||||
|
.expect("string column");
|
||||||
|
values.extend(column.iter().map(|value| value.expect("selected value").to_owned()));
|
||||||
|
}
|
||||||
|
assert_eq!(values, ["selected"], "key={key}, json={json}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn csv_query_uses_custom_record_delimiter_across_file_partitions() {
|
async fn csv_query_uses_custom_record_delimiter_across_file_partitions() {
|
||||||
const ROW_COUNT: usize = 200_000;
|
const ROW_COUNT: usize = 200_000;
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ use metrics::{counter, describe_counter, describe_histogram, histogram};
|
|||||||
use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS;
|
use rustfs_config::ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS;
|
||||||
pub use rustfs_data_usage::{
|
pub use rustfs_data_usage::{
|
||||||
AllTierStats, BucketTargetUsageInfo, BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
|
AllTierStats, BucketTargetUsageInfo, BucketUsageInfo, DATA_USAGE_OBJECT_NAME, DATA_USAGE_OBSERVED_OBJECT_NAME,
|
||||||
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSnapshotSetState, LEGACY_DATA_USAGE_OBJECT_NAME,
|
DataUsageEntry, DataUsageHash, DataUsageHashMap, DataUsageInfo, DataUsageSegmentInvalidationProof, DataUsageSnapshotSetState,
|
||||||
PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary, SizeReconciliationEntry,
|
LEGACY_DATA_USAGE_OBJECT_NAME, PrefixUsageEntry, PrefixUsageQuery, PrefixUsageSummary, ReplTargetSizeSummary,
|
||||||
SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER, UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP,
|
SizeReconciliationEntry, SizeReconciliationScope, SizeSummary, TierAccountingProof, TierStats, UNKNOWN_TIER,
|
||||||
UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
|
UNKNOWN_TIER_DIAGNOSTIC_BYTE_CAP, UNKNOWN_TIER_DIAGNOSTIC_ENTRY_CAP, UnknownTierStats, hash_path, prefix_usage_in_cache,
|
||||||
};
|
};
|
||||||
use rustfs_heal_contracts::heal_channel::HealScanMode;
|
use rustfs_heal_contracts::heal_channel::HealScanMode;
|
||||||
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
|
use rustfs_utils::path::{SLASH_SEPARATOR, path_join_buf};
|
||||||
@@ -657,6 +657,11 @@ pub struct DataUsageCacheInfo {
|
|||||||
/// structural plan remains reusable across ordinary bucket writes.
|
/// structural plan remains reusable across ordinary bucket writes.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
|
pub scan_execution_digest: Option<DataUsageScanPlanDigest>,
|
||||||
|
/// Process-epoch and generation window that produced a complete set cache
|
||||||
|
/// with all known segment invalidation producers wired. This proof is
|
||||||
|
/// additive compatibility metadata; absence keeps segment reuse disabled.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
|
||||||
/// Durable bucket incarnations captured for a complete set aggregate.
|
/// Durable bucket incarnations captured for a complete set aggregate.
|
||||||
/// Missing or nil entries are legacy/unproven and cannot authorize
|
/// Missing or nil entries are legacy/unproven and cannot authorize
|
||||||
/// skipping an unselected bucket in a later scoped set scan.
|
/// skipping an unselected bucket in a later scoped set scan.
|
||||||
@@ -686,6 +691,7 @@ impl Serialize for DataUsageCacheInfo {
|
|||||||
+ usize::from(self.lkg_leader_epoch.is_some())
|
+ usize::from(self.lkg_leader_epoch.is_some())
|
||||||
+ usize::from(self.lkg_scan_plan_digest.is_some())
|
+ usize::from(self.lkg_scan_plan_digest.is_some())
|
||||||
+ usize::from(self.scan_execution_digest.is_some())
|
+ usize::from(self.scan_execution_digest.is_some())
|
||||||
|
+ usize::from(self.segment_invalidation_proof.is_some())
|
||||||
+ usize::from(!self.scan_bucket_incarnations.is_empty());
|
+ usize::from(!self.scan_bucket_incarnations.is_empty());
|
||||||
let mut state = serializer.serialize_map(Some(field_count))?;
|
let mut state = serializer.serialize_map(Some(field_count))?;
|
||||||
state.serialize_entry("name", &self.name)?;
|
state.serialize_entry("name", &self.name)?;
|
||||||
@@ -746,6 +752,9 @@ impl Serialize for DataUsageCacheInfo {
|
|||||||
if let Some(scan_execution_digest) = self.scan_execution_digest {
|
if let Some(scan_execution_digest) = self.scan_execution_digest {
|
||||||
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
|
state.serialize_entry("scan_execution_digest", &scan_execution_digest)?;
|
||||||
}
|
}
|
||||||
|
if let Some(proof) = &self.segment_invalidation_proof {
|
||||||
|
state.serialize_entry("segment_invalidation_proof", proof)?;
|
||||||
|
}
|
||||||
if !self.scan_bucket_incarnations.is_empty() {
|
if !self.scan_bucket_incarnations.is_empty() {
|
||||||
state.serialize_entry("scan_bucket_incarnations", &self.scan_bucket_incarnations)?;
|
state.serialize_entry("scan_bucket_incarnations", &self.scan_bucket_incarnations)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1095,6 +1095,7 @@ fn test_data_usage_cache_info_deserialize_defaults_scan_resume_after() {
|
|||||||
assert!(!decoded.snapshot_complete);
|
assert!(!decoded.snapshot_complete);
|
||||||
assert!(decoded.scan_plan_digest.is_none());
|
assert!(decoded.scan_plan_digest.is_none());
|
||||||
assert!(decoded.scan_execution_digest.is_none());
|
assert!(decoded.scan_execution_digest.is_none());
|
||||||
|
assert!(decoded.segment_invalidation_proof.is_none());
|
||||||
assert_eq!(decoded.cache_key_format, 0);
|
assert_eq!(decoded.cache_key_format, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,6 +1184,13 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
|||||||
snapshot_complete: true,
|
snapshot_complete: true,
|
||||||
scan_plan_digest: Some(TEST_PLAN_DIGEST),
|
scan_plan_digest: Some(TEST_PLAN_DIGEST),
|
||||||
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
|
scan_execution_digest: Some(DataUsageScanPlanDigest([42; 32])),
|
||||||
|
segment_invalidation_proof: Some(DataUsageSegmentInvalidationProof {
|
||||||
|
process_epoch: "scanner-process".to_string(),
|
||||||
|
generation_start: 7,
|
||||||
|
generation_end: 9,
|
||||||
|
producer_identity_coverage_complete: true,
|
||||||
|
cold_zero_walk_oracle: true,
|
||||||
|
}),
|
||||||
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
cache_key_format: DATA_USAGE_CACHE_KEY_FORMAT,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -1212,6 +1220,16 @@ fn test_new_data_usage_cache_msgpack_round_trips_and_supports_old_reader() {
|
|||||||
assert!(current.info.snapshot_complete);
|
assert!(current.info.snapshot_complete);
|
||||||
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
|
assert_eq!(current.info.scan_plan_digest, Some(TEST_PLAN_DIGEST));
|
||||||
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
|
assert_eq!(current.info.scan_execution_digest, Some(DataUsageScanPlanDigest([42; 32])));
|
||||||
|
assert_eq!(
|
||||||
|
current.info.segment_invalidation_proof,
|
||||||
|
Some(DataUsageSegmentInvalidationProof {
|
||||||
|
process_epoch: "scanner-process".to_string(),
|
||||||
|
generation_start: 7,
|
||||||
|
generation_end: 9,
|
||||||
|
producer_identity_coverage_complete: true,
|
||||||
|
cold_zero_walk_oracle: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
|
assert_eq!(current.info.cache_key_format, DATA_USAGE_CACHE_KEY_FORMAT);
|
||||||
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
|
assert_eq!(current.find("bucket").map(|entry| entry.objects), Some(3));
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,11 @@ pub use scanner::{
|
|||||||
pub use scanner_io::{
|
pub use scanner_io::{
|
||||||
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
|
ScannerDirtyUsageAckError, ScannerDirtyUsageBucket, ScannerDirtyUsageSnapshot, ScannerDirtyUsageState,
|
||||||
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
|
acknowledge_dirty_usage_generation, acknowledge_scoped_dirty_usage, clear_dirty_usage_bucket, record_dirty_usage_bucket,
|
||||||
record_dirty_usage_object, record_scanner_maintenance_change, scanner_activity_epoch, scanner_dirty_usage_snapshot,
|
record_dirty_usage_bucket_from_producer, record_dirty_usage_bucket_from_producers, record_dirty_usage_object,
|
||||||
scanner_dirty_usage_state, scanner_maintenance_generation,
|
record_dirty_usage_object_from_producer, record_scanner_maintenance_change, scanner_activity_epoch,
|
||||||
|
scanner_dirty_usage_snapshot, scanner_dirty_usage_state, scanner_maintenance_generation,
|
||||||
};
|
};
|
||||||
|
pub use segment_invalidation::SegmentInvalidationProducerIdentity;
|
||||||
pub use sleeper::{DynamicSleeper, SCANNER_IDLE_MODE, SCANNER_SLEEPER};
|
pub use sleeper::{DynamicSleeper, SCANNER_IDLE_MODE, SCANNER_SLEEPER};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
pub use storage_api::ScannerReplicationConfig as ReplicationConfig;
|
pub use storage_api::ScannerReplicationConfig as ReplicationConfig;
|
||||||
|
|||||||
@@ -964,9 +964,10 @@ async fn run_scanner_usage_recovery_intents_for_startup(
|
|||||||
Ok(attempted)
|
Ok(attempted)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start normal scanning when enabled, or one resume-only cleanup attempt.
|
/// Start normal scanning when enabled, or one bounded recovery attempt.
|
||||||
/// The disabled branch returns a finite task for the startup owner to join;
|
/// The disabled branch returns a finite task for the startup owner to join;
|
||||||
/// it never enables ordinary namespace scanning or accepts a new reset intent.
|
/// it never enables ordinary namespace scanning while it replays durable reset
|
||||||
|
/// intents and cleanup markers.
|
||||||
pub async fn init_scanner_with_recovery(
|
pub async fn init_scanner_with_recovery(
|
||||||
ctx: CancellationToken,
|
ctx: CancellationToken,
|
||||||
storeapi: Arc<ECStore>,
|
storeapi: Arc<ECStore>,
|
||||||
@@ -988,6 +989,17 @@ pub async fn init_scanner_with_recovery(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(tokio::spawn(async move {
|
Some(tokio::spawn(async move {
|
||||||
|
if let Err(error) = run_scanner_usage_recovery_intents_for_startup(ctx.clone(), storeapi.clone()).await {
|
||||||
|
warn!(
|
||||||
|
target: "rustfs::scanner",
|
||||||
|
event = EVENT_SCANNER_PERSIST_STATE,
|
||||||
|
component = LOG_COMPONENT_SCANNER,
|
||||||
|
subsystem = LOG_SUBSYSTEM_RUNTIME,
|
||||||
|
state = "recovery_intent_startup_discovery_failed",
|
||||||
|
error = %error,
|
||||||
|
"Scanner recovery intent startup discovery failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Err(error) = resume_scanner_cycle_cleanup(ctx, storeapi).await {
|
if let Err(error) = resume_scanner_cycle_cleanup(ctx, storeapi).await {
|
||||||
warn!(
|
warn!(
|
||||||
target: "rustfs::scanner",
|
target: "rustfs::scanner",
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ use super::ScannerCycleOutcome;
|
|||||||
use crate::data_usage_define::DataUsageCacheRevision;
|
use crate::data_usage_define::DataUsageCacheRevision;
|
||||||
use crate::storage_api::ScannerStorage;
|
use crate::storage_api::ScannerStorage;
|
||||||
use crate::storage_api::owner::ObjectIO as _;
|
use crate::storage_api::owner::ObjectIO as _;
|
||||||
use crate::{
|
use crate::{BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks};
|
||||||
BUCKET_META_PREFIX, ECStore, EcstoreError, RUSTFS_META_BUCKET, ScannerObjectOptions, SetDisks, save_config_with_preconditions,
|
|
||||||
};
|
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -1195,13 +1193,14 @@ where
|
|||||||
};
|
};
|
||||||
let revision = revisions.get(&id).cloned();
|
let revision = revisions.get(&id).cloned();
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
|
let storeapi = storeapi.clone();
|
||||||
async move {
|
async move {
|
||||||
let Some(revision) = revision else {
|
let Some(revision) = revision else {
|
||||||
return (id, Err("replica revision is unavailable".to_string()));
|
return (id, Err("replica revision is unavailable".to_string()));
|
||||||
};
|
};
|
||||||
let result = save_config_with_preconditions(set, SCANNER_PAUSE_BACKLOG_PATH.as_str(), data, revision.preconditions())
|
let result = storeapi
|
||||||
|
.save_scanner_pause_backlog_replica(id.pool_index, id.set_index, data, revision.preconditions())
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|err| err.to_string());
|
.map_err(|err| err.to_string());
|
||||||
(id, result)
|
(id, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,79 @@ pub(super) mod cleanup_io_fault {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) mod recovery_intent_accept_fault {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
enum Fault {
|
||||||
|
Corrupt,
|
||||||
|
Running,
|
||||||
|
}
|
||||||
|
|
||||||
|
static NEXT_ACCEPT_READBACK_FAULT: StdMutex<Option<Fault>> = StdMutex::new(None);
|
||||||
|
|
||||||
|
pub(in crate::scanner) struct Guard;
|
||||||
|
|
||||||
|
impl Drop for Guard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
*NEXT_ACCEPT_READBACK_FAULT
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::scanner) fn corrupt_next_accept_readback() -> Guard {
|
||||||
|
install(Fault::Corrupt)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::scanner) fn advance_next_accept_readback_to_running() -> Guard {
|
||||||
|
install(Fault::Running)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(fault: Fault) -> Guard {
|
||||||
|
let mut slot = NEXT_ACCEPT_READBACK_FAULT
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
assert!(slot.is_none(), "only one recovery intent accept readback fault may be installed");
|
||||||
|
*slot = Some(fault);
|
||||||
|
Guard
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn maybe_apply<S>(storeapi: Arc<S>, path: &str) -> Result<(), ScannerError>
|
||||||
|
where
|
||||||
|
S: ScannerObjectIO,
|
||||||
|
{
|
||||||
|
let Some(fault) = NEXT_ACCEPT_READBACK_FAULT
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.take()
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
match fault {
|
||||||
|
Fault::Corrupt => save_config(storeapi, path, b"{corrupt".to_vec()).await.map_err(|err| {
|
||||||
|
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
|
||||||
|
}),
|
||||||
|
Fault::Running => {
|
||||||
|
let mut record = read_recovery_intent_record(storeapi.clone(), path).await?.ok_or_else(|| {
|
||||||
|
ScannerError::Other("scanner recovery intent disappeared before fault injection".to_string())
|
||||||
|
})?;
|
||||||
|
record.state = SCANNER_RECOVERY_INTENT_STATE_RUNNING.to_string();
|
||||||
|
save_config(
|
||||||
|
storeapi,
|
||||||
|
path,
|
||||||
|
serde_json::to_vec(&record)
|
||||||
|
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent fault: {err}")))?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
ScannerError::Other(format!("failed to inject scanner recovery intent accept readback fault: {err}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
pub struct ScannerCycleRecoveryStatus {
|
pub struct ScannerCycleRecoveryStatus {
|
||||||
/// The immutable primary object whose revision is being guarded.
|
/// The immutable primary object whose revision is being guarded.
|
||||||
@@ -564,6 +637,17 @@ fn compare_recovery_intent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn confirm_recovery_intent_acceptance(
|
||||||
|
expected: ScannerRecoveryIntentRecord,
|
||||||
|
persisted: ScannerRecoveryIntentRecord,
|
||||||
|
) -> ScannerRecoveryIntentAcceptResult {
|
||||||
|
if persisted == expected {
|
||||||
|
ScannerRecoveryIntentAcceptResult::Accepted { record: persisted }
|
||||||
|
} else {
|
||||||
|
compare_recovery_intent(&expected, persisted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn read_recovery_intent_record(
|
async fn read_recovery_intent_record(
|
||||||
storeapi: Arc<impl ScannerObjectIO>,
|
storeapi: Arc<impl ScannerObjectIO>,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -757,7 +841,20 @@ pub async fn accept_scanner_usage_recovery_intent(
|
|||||||
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent: {err}")))?;
|
.map_err(|err| ScannerError::Other(format!("failed to encode scanner recovery intent: {err}")))?;
|
||||||
match save_config_with_preconditions(storeapi.clone(), &path, encoded, DataUsageCacheRevision::Missing.preconditions()).await
|
match save_config_with_preconditions(storeapi.clone(), &path, encoded, DataUsageCacheRevision::Missing.preconditions()).await
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(ScannerRecoveryIntentAcceptResult::Accepted { record: candidate }),
|
Ok(_) => {
|
||||||
|
#[cfg(test)]
|
||||||
|
recovery_intent_accept_fault::maybe_apply(storeapi.clone(), &path).await?;
|
||||||
|
let persisted = match read_recovery_intent_record(storeapi.clone(), &path).await {
|
||||||
|
Ok(Some(record)) => record,
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(ScannerError::Other(
|
||||||
|
"scanner recovery intent disappeared before acceptance confirmation".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
Ok(confirm_recovery_intent_acceptance(candidate, persisted))
|
||||||
|
}
|
||||||
Err(EcstoreError::PreconditionFailed) => {
|
Err(EcstoreError::PreconditionFailed) => {
|
||||||
let existing = read_recovery_intent_record(storeapi, &path).await?;
|
let existing = read_recovery_intent_record(storeapi, &path).await?;
|
||||||
let Some(existing) = existing else {
|
let Some(existing) = existing else {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user