mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Merge main into gateway/integration
Picks up the 20 main commits since 62b6f5f3f. The one conflict is the tier free-version recovery test setup: it keeps the integration-only fixture isolation (background recovery disabled while the fixture starts) and main's mock tier registration (#8804 skips scans with no tier). The e2e selections are unchanged by these commits; the test-count baseline is regenerated.
This commit is contained in:
@@ -101,8 +101,11 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
|
||||
# Reserve capacity across test processes while preserving each scenario's fixed
|
||||
# budget, assertions, and internal concurrency.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.scripts]]
|
||||
@@ -381,8 +384,11 @@ threads-required = "num-test-threads"
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Native scanner backlog fixtures include 24-set and pool-expansion cases.
|
||||
# Reserve capacity across test processes while preserving each scenario's fixed
|
||||
# budget, assertions, and internal concurrency.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
|
||||
@@ -10,24 +10,24 @@
|
||||
# intentionally removed. Regenerate with:
|
||||
# scripts/check_test_count_ratchet.sh --update
|
||||
e2e_test 934 19
|
||||
rustfs 5806 63
|
||||
rustfs 5814 63
|
||||
rustfs-audit 70 0
|
||||
rustfs-checksums 26 0
|
||||
rustfs-common 26 0
|
||||
rustfs-common 36 0
|
||||
rustfs-concurrency 27 0
|
||||
rustfs-config 43 0
|
||||
rustfs-connect-symbol-catalog 6 0
|
||||
rustfs-credentials 24 0
|
||||
rustfs-crypto 67 0
|
||||
rustfs-data-usage 55 0
|
||||
rustfs-ecstore 6933 1
|
||||
rustfs-ecstore 6947 1
|
||||
rustfs-extension-schema 23 0
|
||||
rustfs-filemeta 335 0
|
||||
rustfs-heal 934 3
|
||||
rustfs-filemeta 349 0
|
||||
rustfs-heal 943 3
|
||||
rustfs-heal-contracts 6 0
|
||||
rustfs-iam 394 0
|
||||
rustfs-io-core 49 0
|
||||
rustfs-io-metrics 142 0
|
||||
rustfs-io-metrics 151 0
|
||||
rustfs-keystone 29 0
|
||||
rustfs-kms 715 25
|
||||
rustfs-license 2 0
|
||||
@@ -38,7 +38,7 @@ rustfs-madmin 210 0
|
||||
rustfs-notify 136 0
|
||||
rustfs-object-capacity 92 0
|
||||
rustfs-object-data-cache 140 0
|
||||
rustfs-obs 378 0
|
||||
rustfs-obs 379 0
|
||||
rustfs-policy 725 0
|
||||
rustfs-protocols 736 1
|
||||
rustfs-protos 65 0
|
||||
@@ -50,7 +50,7 @@ rustfs-s3-ops 8 0
|
||||
rustfs-s3-types 99 0
|
||||
rustfs-s3select-api 207 0
|
||||
rustfs-s3select-query 189 2
|
||||
rustfs-scanner 1104 20
|
||||
rustfs-scanner 1113 20
|
||||
rustfs-scanner-metrics 84 0
|
||||
rustfs-security-governance 20 0
|
||||
rustfs-signer 31 0
|
||||
@@ -59,5 +59,5 @@ rustfs-targets 603 26
|
||||
rustfs-test-utils 1 0
|
||||
rustfs-tls-runtime 43 0
|
||||
rustfs-trusted-proxies 68 0
|
||||
rustfs-utils 238 0
|
||||
rustfs-utils 240 0
|
||||
rustfs-zip 30 0
|
||||
|
||||
Generated
+126
-123
@@ -17,12 +17,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "adler32"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.6.1"
|
||||
@@ -966,9 +960,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-kms"
|
||||
version = "1.124.0"
|
||||
version = "1.125.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d08f45fc138774d0421ed7209a568e9da5356d8cb309ca3aa39b8a2481257d94"
|
||||
checksum = "0dfd217421610bb87b86a2cc8f5a29b8dd836096dbe649742a50d1e85c3f2849"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -991,9 +985,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.153.0"
|
||||
version = "1.154.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5bee59f57aeef2dbbfbee57f50405cbc07a5cacbba40f350a317dfcdb10916"
|
||||
checksum = "910c6e60689f021154dc701ad219035066fb94decce71ee72ff0f06e90df4067"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -1027,9 +1021,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sso"
|
||||
version = "1.115.0"
|
||||
version = "1.116.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ec003d17bc67708c440a5f7fd6f73c6b5e9842957a97bb476138944d2766c5d"
|
||||
checksum = "f26d2c93a4e6e7c133dc4090dc2273cfd92894fee8b883256427b57cacc3a384"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -1052,9 +1046,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-ssooidc"
|
||||
version = "1.117.0"
|
||||
version = "1.118.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e071d2f824173edc63cd888d7fe4fef8b0012bafccf8c3ec2765f293b7fbdb8"
|
||||
checksum = "d6fa86283d1c6d5c82f1fb2d82f7704d8f7a7bf5f0591813114240d296f6a11c"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -1077,9 +1071,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sts"
|
||||
version = "1.120.0"
|
||||
version = "1.121.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61e925e0ab1617457f7d6e69be27b83143a5493a6f99bcd0107ae7a498a4052c"
|
||||
checksum = "97ff04fe350fd756c1aa45544128e88f72ffb6281cad628e5a4add464cbbab8b"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -1162,9 +1156,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-eventstream"
|
||||
version = "0.61.5"
|
||||
version = "0.61.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4aecd9c411046779355be31692bdd451ae35b3983ff40dd65258edffcc1a1ce"
|
||||
checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -1327,9 +1321,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-schema"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d7a3b01a2a1b453176457b260b0ed55d33de61d35d21b74022c51ec62a42895"
|
||||
checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -2840,12 +2834,6 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dary_heap"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
@@ -2855,9 +2843,9 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"lock_api 0.4.14",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
"parking_lot_core 0.9.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2906,7 +2894,7 @@ dependencies = [
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"object_store",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"parquet",
|
||||
"sqlparser",
|
||||
"tempfile",
|
||||
@@ -2936,7 +2924,7 @@ dependencies = [
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"object_store",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -3026,7 +3014,7 @@ dependencies = [
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"object_store",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"rand 0.9.5",
|
||||
"tokio",
|
||||
"url",
|
||||
@@ -3129,7 +3117,7 @@ dependencies = [
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"object_store",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"parquet",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3157,7 +3145,7 @@ dependencies = [
|
||||
"futures",
|
||||
"log",
|
||||
"object_store",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"tempfile",
|
||||
@@ -3301,7 +3289,7 @@ dependencies = [
|
||||
"datafusion-expr",
|
||||
"datafusion-physical-expr",
|
||||
"datafusion-physical-plan",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3379,7 +3367,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap 2.14.2",
|
||||
"itertools 0.15.0",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"petgraph 0.8.3",
|
||||
"recursive",
|
||||
"tokio",
|
||||
@@ -3413,7 +3401,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap 2.14.2",
|
||||
"itertools 0.15.0",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"pin-project",
|
||||
]
|
||||
|
||||
@@ -3466,7 +3454,7 @@ dependencies = [
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"num-traits",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3501,7 +3489,7 @@ dependencies = [
|
||||
"datafusion-execution",
|
||||
"datafusion-expr",
|
||||
"datafusion-physical-plan",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3545,7 +3533,7 @@ dependencies = [
|
||||
"log",
|
||||
"lru 0.16.4",
|
||||
"mime_guess",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"reflink-copy",
|
||||
@@ -4772,10 +4760,23 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-auth"
|
||||
version = "1.17.0"
|
||||
name = "google-cloud-api"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9870b8ea484519aa8601e14886f2efc148ea490034e43d04bef1d246170cc9"
|
||||
checksum = "4212525a72252d4d5359dfc0b19dd8039cb898f54458fb6357f56162c5b35a33"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"google-cloud-wkt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-auth"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d563c7b987f40249c274a4e6a671661df6be2d084f08476dda2f0f931576cfcf"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
@@ -4822,12 +4823,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-gax-internal"
|
||||
version = "0.7.20"
|
||||
version = "0.7.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e873f7e6e39030c07b6d9697bf3b999a269175c8b433e058f56ae10d129f12f7"
|
||||
checksum = "6ad2379de0de5562c27eb79acd9c36143719a9271191df5d0ac6eb162a1b9d7e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"google-cloud-api",
|
||||
"google-cloud-auth",
|
||||
"google-cloud-gax",
|
||||
"google-cloud-rpc",
|
||||
@@ -4926,9 +4928,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-storage"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "570eb8d0784118a5f4ae1219fd76ff3b14e90b7b0f877478c02b1374e9bbcb8c"
|
||||
checksum = "8343454507711c984d4b367ccd79fe66284b63f7e90299c800e2d407e9bbe626"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -4979,9 +4981,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-wkt"
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f3037907e542cf8b8580aa4cf8ad68568d7ac8e7274519a7d0a121867d434da"
|
||||
checksum = "75baa147c141fa2b11047827e7ff4eea3a84ab00c3d2cf44fccd132b2ed326cf"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
@@ -5263,7 +5265,7 @@ dependencies = [
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"rand 0.10.3",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
@@ -5347,7 +5349,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"libc",
|
||||
"object 0.36.7",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"pin-project-lite",
|
||||
"quanta",
|
||||
"regex-lite",
|
||||
@@ -6167,30 +6169,6 @@ version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libflate"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "561a8da1a50e1428d3c51321dafeca849df992a5bb67720c386131234caba82e"
|
||||
dependencies = [
|
||||
"adler32",
|
||||
"crc32fast",
|
||||
"dary_heap",
|
||||
"libflate_lz77",
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libflate_lz77"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"no_std_io2",
|
||||
"rle-decode-fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
@@ -6318,6 +6296,15 @@ dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
@@ -6480,9 +6467,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -6732,7 +6719,7 @@ dependencies = [
|
||||
"equivalent",
|
||||
"event-listener",
|
||||
"futures-util",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
@@ -6864,7 +6851,7 @@ dependencies = [
|
||||
"linux-raw-sys",
|
||||
"log",
|
||||
"neli-proc-macros",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6951,15 +6938,6 @@ dependencies = [
|
||||
"signatory",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -7289,7 +7267,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"humantime",
|
||||
"itertools 0.14.0",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"percent-encoding",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
@@ -7662,8 +7640,18 @@ version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
"lock_api 0.4.14",
|
||||
"parking_lot_core 0.9.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226"
|
||||
dependencies = [
|
||||
"lock_api 0.5.0",
|
||||
"parking_lot_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7674,7 +7662,19 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_syscall 0.9.5",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -8178,9 +8178,9 @@ checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.5.1"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56"
|
||||
checksum = "cd4acc8d264ea385db64ccfdcdfd5909ee9eda0975666723d59f3bfec80b3611"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
@@ -8320,7 +8320,7 @@ dependencies = [
|
||||
"fnv",
|
||||
"lazy_static",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -8471,9 +8471,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"memchr",
|
||||
@@ -8523,16 +8523,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pyroscope"
|
||||
version = "2.1.2"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357782a4af9b6b43bd3a9caa64dd802967498b1fce1d4e0bda552267d62de7cf"
|
||||
checksum = "950d3d44ec98b0e904ddd23a49893003f636faf1d0208687874fccf59f5c840e"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"backtrace",
|
||||
"findshlibs",
|
||||
"flate2",
|
||||
"framehop",
|
||||
"libc",
|
||||
"libflate",
|
||||
"log",
|
||||
"memmap2",
|
||||
"nix 0.31.3",
|
||||
@@ -9036,6 +9036,15 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.3"
|
||||
@@ -9226,12 +9235,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rle-decode-fast"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
|
||||
|
||||
[[package]]
|
||||
name = "rmp"
|
||||
version = "0.8.15"
|
||||
@@ -9580,7 +9583,7 @@ dependencies = [
|
||||
"opentelemetry-proto",
|
||||
"opentelemetry_sdk",
|
||||
"p256 0.14.0",
|
||||
"parking_lot",
|
||||
"parking_lot 0.13.0",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"proptest",
|
||||
@@ -9864,7 +9867,7 @@ dependencies = [
|
||||
"num_cpus",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"parking_lot",
|
||||
"parking_lot 0.13.0",
|
||||
"path-absolutize",
|
||||
"pin-project-lite",
|
||||
"proptest",
|
||||
@@ -9945,7 +9948,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"hashlink",
|
||||
"libm",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"rayon",
|
||||
"smallvec",
|
||||
"spin 0.12.3",
|
||||
@@ -10323,7 +10326,7 @@ dependencies = [
|
||||
"crossbeam-queue",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"parking_lot",
|
||||
"parking_lot 0.13.0",
|
||||
"rand 0.10.3",
|
||||
"rustfs-io-metrics",
|
||||
"rustfs-utils",
|
||||
@@ -10377,18 +10380,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-mimalloc"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40353bb1b86c737dd68a403ae2fb4ca0c9917f8f54b1dad3a14f4d6be61ecc3"
|
||||
checksum = "91c0ac4b4c6f8246bb843c985b47f7f8ea3e654e02d965a5d6c19fc006813584"
|
||||
dependencies = [
|
||||
"rustfs-mimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfs-mimalloc-sys"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291d2eabf67ff4fcc9e630e8644d0f503a41d016a8adb2d41853538b2bda71e9"
|
||||
checksum = "265332babe807f64b800b1a47d6d39e6897f249c153b962a6b1050f073816342"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
@@ -10824,7 +10827,7 @@ dependencies = [
|
||||
"http 1.5.0",
|
||||
"memchr",
|
||||
"metrics",
|
||||
"parking_lot",
|
||||
"parking_lot 0.13.0",
|
||||
"rustfs-common",
|
||||
"rustfs-ecstore",
|
||||
"rustfs-gateway-types",
|
||||
@@ -10853,7 +10856,7 @@ dependencies = [
|
||||
"derive_builder",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"parking_lot",
|
||||
"parking_lot 0.13.0",
|
||||
"rustfs-s3select-api",
|
||||
"rustfs-test-utils",
|
||||
"serde",
|
||||
@@ -10986,7 +10989,7 @@ dependencies = [
|
||||
"libc",
|
||||
"metrics",
|
||||
"mysql_async",
|
||||
"parking_lot",
|
||||
"parking_lot 0.13.0",
|
||||
"pulsar",
|
||||
"rayon",
|
||||
"rcgen",
|
||||
@@ -11824,7 +11827,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
@@ -12134,7 +12137,7 @@ version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"lock_api 0.4.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12149,7 +12152,7 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"lock_api 0.4.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12572,7 +12575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12782,9 +12785,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.54.0"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -12820,7 +12823,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"log",
|
||||
"parking_lot",
|
||||
"parking_lot 0.12.5",
|
||||
"percent-encoding",
|
||||
"phf 0.13.1",
|
||||
"pin-project-lite",
|
||||
|
||||
+10
-10
@@ -175,7 +175,7 @@ minlz = "1.2.3"
|
||||
reqwest = "0.13.5"
|
||||
rustfs-kafka-async = { version = "1.4.0" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.54.0" }
|
||||
tokio = { version = "1.54.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.6" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.6"
|
||||
@@ -258,9 +258,9 @@ atoi = "3.1.1"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.13.0" }
|
||||
aws-credential-types = { version = "1.3.1" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.124.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.153.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.120.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.125.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.154.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.121.0" }
|
||||
aws-smithy-async = { version = "1.3.1" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.1" }
|
||||
aws-smithy-runtime-api = { version = "1.19.1" }
|
||||
@@ -282,8 +282,8 @@ enumset = "1.1.14"
|
||||
faster-hex = "1.0.0"
|
||||
flate2 = "1.1.10"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.20.0"
|
||||
google-cloud-auth = "1.17.0"
|
||||
google-cloud-storage = "1.21.0"
|
||||
google-cloud-auth = "1.18.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||
# every authenticator app expects). Already in the graph transitively.
|
||||
@@ -297,7 +297,7 @@ libc = "0.2.191"
|
||||
libsystemd = "0.7.2"
|
||||
local-ip-address = "0.6.13"
|
||||
log = "0.4"
|
||||
memmap2 = "0.9.11"
|
||||
memmap2 = "0.9.12"
|
||||
lz4 = "1.28.1"
|
||||
matchit = "0.9.2"
|
||||
md-5 = "0.11.0"
|
||||
@@ -307,7 +307,7 @@ netif = "0.1.6"
|
||||
num_cpus = { version = "1.17.0" }
|
||||
nvml-wrapper = "0.13.0"
|
||||
object = { version = "=0.40.0", default-features = false }
|
||||
parking_lot = "0.12.5"
|
||||
parking_lot = "0.13.0"
|
||||
path-absolutize = "4.0.1"
|
||||
percent-encoding = "2.3.2"
|
||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||
@@ -379,7 +379,7 @@ opentelemetry-proto = { version = "0.33.1", default-features = false, features =
|
||||
opentelemetry_sdk = { version = "0.33.1" }
|
||||
opentelemetry-semantic-conventions = { version = "0.33.1" }
|
||||
opentelemetry-stdout = { version = "0.33.1" }
|
||||
pyroscope = { version = "2.1.2" }
|
||||
pyroscope = { version = "2.1.3" }
|
||||
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.1" }
|
||||
@@ -393,7 +393,7 @@ russh-sftp = "3.0.1"
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.7.0" }
|
||||
rustfs-mimalloc = { version = "0.7.1" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.28.7", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
|
||||
@@ -29,6 +29,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-util = []
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
@@ -97,6 +97,18 @@ pub fn try_get_global_local_node_name() -> Option<String> {
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// Run a synchronous test callback while publishing a name under its writer lock.
|
||||
/// Call from a blocking worker; returning or unwinding releases the writer.
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
#[doc(hidden)]
|
||||
pub fn with_local_node_name_write_lock_for_test<T>(name: &str, work: impl FnOnce() -> T) -> T {
|
||||
let mut writer = GLOBAL_LOCAL_NODE_NAME.blocking_write();
|
||||
*writer = name.to_string();
|
||||
let result = work();
|
||||
drop(writer);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod node_identity_tests {
|
||||
use super::*;
|
||||
@@ -104,15 +116,23 @@ mod node_identity_tests {
|
||||
#[tokio::test]
|
||||
async fn local_node_name_nonblocking_read_recovers_after_writer_release() {
|
||||
let previous = get_global_local_node_name().await;
|
||||
let mut writer = GLOBAL_LOCAL_NODE_NAME.write().await;
|
||||
*writer = "published-node".to_string();
|
||||
let unavailable = try_get_global_local_node_name();
|
||||
drop(writer);
|
||||
let published = try_get_global_local_node_name();
|
||||
let (unavailable, published, panicked) = tokio::task::spawn_blocking(|| {
|
||||
let unavailable = with_local_node_name_write_lock_for_test("published-node", try_get_global_local_node_name);
|
||||
let published = try_get_global_local_node_name();
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
with_local_node_name_write_lock_for_test("unwound-node", || panic!("identity callback failed"));
|
||||
});
|
||||
(unavailable, published, panicked)
|
||||
})
|
||||
.await
|
||||
.expect("identity writer worker must complete");
|
||||
let after_unwind = try_get_global_local_node_name();
|
||||
set_global_local_node_name(&previous).await;
|
||||
|
||||
assert!(unavailable.is_none(), "a held writer must make the nonblocking getter unavailable");
|
||||
assert_eq!(published.as_deref(), Some("published-node"));
|
||||
assert!(panicked.is_err(), "the callback panic must propagate after releasing the lock");
|
||||
assert_eq!(after_unwind.as_deref(), Some("unwound-node"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,11 +172,41 @@ pub struct MrfUnverifiedLegacyEvent {
|
||||
pub anchor: MrfDurableRepairAnchor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ResolvedMrfRepairIdentity {
|
||||
version_id: Option<[u8; 16]>,
|
||||
scope: Option<MrfScope>,
|
||||
delete_marker_purge: Option<MrfDeleteMarkerPurgeIdentity>,
|
||||
lease: MrfIngressLease,
|
||||
bucket_incarnation_id: Uuid,
|
||||
}
|
||||
|
||||
impl MrfDurableRepairAnchor {
|
||||
/// Build a dischargeable anchor only when the caller supplies the storage
|
||||
/// incarnation and the original ingress lease. Legacy replay records lack
|
||||
/// both pieces and therefore remain fail-closed.
|
||||
pub fn from_intent(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<Self> {
|
||||
let identity = Self::resolve_identity(intent, bucket_incarnation_id)?;
|
||||
Some(Self {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
version_id: identity.version_id,
|
||||
scope: identity.scope,
|
||||
delete_marker_purge: identity.delete_marker_purge,
|
||||
lease: identity.lease,
|
||||
bucket_incarnation_id: identity.bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether the intent can form an anchor for this exact source,
|
||||
/// without retaining its bucket/object handles. This checks identity shape,
|
||||
/// not storage verification or retirement authority.
|
||||
pub fn is_eligible_for_source(intent: &MrfIntent, source: Uuid) -> bool {
|
||||
Self::resolve_identity(intent, source).is_some_and(|identity| identity.bucket_incarnation_id == source)
|
||||
}
|
||||
|
||||
fn resolve_identity(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<ResolvedMrfRepairIdentity> {
|
||||
let lease = intent.lease?;
|
||||
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
|
||||
let delete_marker_purge = canonical_delete_marker_purge_identity(intent)?;
|
||||
@@ -186,10 +216,7 @@ impl MrfDurableRepairAnchor {
|
||||
if bucket_incarnation_id.is_nil() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
Some(ResolvedMrfRepairIdentity {
|
||||
version_id,
|
||||
scope,
|
||||
delete_marker_purge,
|
||||
@@ -233,24 +260,32 @@ pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &m
|
||||
let Ok(mut events) = registry.lock() else {
|
||||
return 0;
|
||||
};
|
||||
consume_verified_mrf_repair_events_from_queue(bucket, anchors, &mut events)
|
||||
}
|
||||
|
||||
fn consume_verified_mrf_repair_events_from_queue(
|
||||
bucket: &str,
|
||||
anchors: &mut Vec<MrfDurableRepairAnchor>,
|
||||
events: &mut std::collections::VecDeque<MrfVerifiedRepairEvent>,
|
||||
) -> usize {
|
||||
let before = anchors.len();
|
||||
let mut retained = std::collections::VecDeque::with_capacity(events.len());
|
||||
while let Some(event) = events.pop_front() {
|
||||
let previous_event_count = events.len();
|
||||
events.retain(|event| {
|
||||
if event.bucket.as_ref() != bucket {
|
||||
retained.push_back(event);
|
||||
continue;
|
||||
return true;
|
||||
}
|
||||
let mut matched = false;
|
||||
anchors.retain(|anchor| {
|
||||
let proven = anchor.is_proven_by(&event);
|
||||
let proven = anchor.is_proven_by(event);
|
||||
matched |= proven;
|
||||
!proven
|
||||
});
|
||||
if !matched {
|
||||
retained.push_back(event);
|
||||
}
|
||||
}
|
||||
*events = retained;
|
||||
!matched
|
||||
});
|
||||
// The old queue rebuild reserved the pre-consumption event count. Reuse
|
||||
// that buffer when possible without extending its capacity lifetime:
|
||||
// an emptied queue releases its backing storage on the next consumption.
|
||||
events.shrink_to(previous_event_count);
|
||||
before.saturating_sub(anchors.len())
|
||||
}
|
||||
|
||||
@@ -821,6 +856,21 @@ static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::V
|
||||
static MRF_UNVERIFIED_LEGACY_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfUnverifiedLegacyEvent>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Whether durable repair events may need consumption. This is only a work
|
||||
/// hint: either queue can receive an event after its empty observation, and a
|
||||
/// bounded queue can evict that event before the next consumer invocation.
|
||||
/// An unavailable lock is unknown, so callers must keep their normal path.
|
||||
/// An allocated empty verified queue also needs the normal consumer pass to
|
||||
/// release its buffer; this hint must not extend that buffer's lifetime.
|
||||
pub fn mrf_durable_repair_events_may_be_pending() -> bool {
|
||||
MRF_VERIFIED_REPAIR_EVENTS
|
||||
.get()
|
||||
.is_some_and(|registry| registry.try_lock().map_or(true, |events| events.capacity() != 0))
|
||||
|| MRF_UNVERIFIED_LEGACY_EVENTS
|
||||
.get()
|
||||
.is_some_and(|registry| registry.try_lock().map_or(true, |events| !events.is_empty()))
|
||||
}
|
||||
|
||||
/// Record a legacy notification for compatibility. This is not an
|
||||
/// acknowledgement of storage verification or durable repair completion.
|
||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||
@@ -935,6 +985,15 @@ pub fn take_mrf_unverified_legacy_events_for(bucket: &str) -> Vec<MrfUnverifiedL
|
||||
taken
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod event_presence_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod proof_queue_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod repair_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// 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::*;
|
||||
|
||||
fn isolated(name: &str) -> bool {
|
||||
const CHILD: &str = "RUSTFS_MRF_EVENT_PRESENCE_TEST";
|
||||
let test = format!("mrf_channel::event_presence_tests::{name}");
|
||||
if let Ok(current) = std::env::var(CHILD) {
|
||||
assert_eq!(current, test, "only the selected registry test may run in the child");
|
||||
return false;
|
||||
}
|
||||
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([&test, "--exact", "--test-threads=1"])
|
||||
.env(CHILD, &test)
|
||||
.output()
|
||||
.expect("run isolated global registry test");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("1 passed; 0 failed"),
|
||||
"registry child must execute exactly once: {stdout}{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_covers_both_queues_and_contention_without_consuming() {
|
||||
if isolated("event_presence_covers_both_queues_and_contention_without_consuming") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let anchor = MrfDurableRepairAnchor {
|
||||
kind: MrfKind::PartialWrite,
|
||||
bucket: Arc::from("presence-test"),
|
||||
object: Arc::from("object"),
|
||||
version_id: Some([7; 16]),
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
}),
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(21),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
};
|
||||
let event = MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: None,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::VerifiedHealthy,
|
||||
};
|
||||
note_mrf_verified_repair(event.clone());
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "the hint must not consume a proof");
|
||||
assert_eq!(take_mrf_verified_repair_events_for(&anchor.bucket), vec![event]);
|
||||
assert!(
|
||||
mrf_durable_repair_events_may_be_pending(),
|
||||
"the empty allocated verified buffer still needs its original maintenance pass"
|
||||
);
|
||||
let mut retained = vec![anchor.clone()];
|
||||
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&anchor.bucket, &mut retained), 0);
|
||||
assert_eq!(retained, vec![anchor.clone()], "buffer maintenance cannot discharge an anchor");
|
||||
assert_eq!(
|
||||
MRF_VERIFIED_REPAIR_EVENTS
|
||||
.get()
|
||||
.expect("verified registry")
|
||||
.lock()
|
||||
.expect("verified storage")
|
||||
.capacity(),
|
||||
0,
|
||||
"the actual consumer releases empty proof storage before subsequent idle passes can skip it"
|
||||
);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let legacy = MrfUnverifiedLegacyEvent { anchor };
|
||||
note_mrf_unverified_legacy(legacy.clone());
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "legacy-only work must not be skipped");
|
||||
assert_eq!(take_mrf_unverified_legacy_events_for(&legacy.anchor.bucket), vec![legacy]);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let verified = MRF_VERIFIED_REPAIR_EVENTS.get().expect("verified registry initialized");
|
||||
let guard = verified.lock().expect("hold verified registry");
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "contended verified is unknown");
|
||||
drop(guard);
|
||||
let legacy = MRF_UNVERIFIED_LEGACY_EVENTS.get().expect("legacy registry initialized");
|
||||
let guard = legacy.lock().expect("hold legacy registry");
|
||||
assert!(mrf_durable_repair_events_may_be_pending(), "contended legacy is unknown");
|
||||
drop(guard);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_keeps_poisoned_verified_unknown() {
|
||||
if isolated("event_presence_keeps_poisoned_verified_unknown") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let registry = MRF_VERIFIED_REPAIR_EVENTS.get_or_init(|| Mutex::new(Default::default()));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _guard = registry.lock().expect("unpoisoned registry");
|
||||
panic!("poison the isolated verified registry");
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_presence_keeps_poisoned_legacy_unknown() {
|
||||
if isolated("event_presence_keeps_poisoned_legacy_unknown") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let registry = MRF_UNVERIFIED_LEGACY_EVENTS.get_or_init(|| Mutex::new(Default::default()));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _guard = registry.lock().expect("unpoisoned registry");
|
||||
panic!("poison the isolated legacy registry");
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(mrf_durable_repair_events_may_be_pending());
|
||||
assert!(registry.is_poisoned(), "the hint must not clear or recover poisoned state");
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
fn proof_fixture() -> (MrfDurableRepairAnchor, MrfVerifiedRepairEvent) {
|
||||
let anchor = MrfDurableRepairAnchor {
|
||||
kind: MrfKind::PartialWrite,
|
||||
bucket: Arc::from("proof-queue"),
|
||||
object: Arc::from("matched"),
|
||||
version_id: Some([7; 16]),
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 2,
|
||||
}),
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(21),
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
};
|
||||
let event = MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: anchor.delete_marker_purge,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||
};
|
||||
(anchor, event)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_reuses_unmatched_storage_in_contiguous_and_wrapped_layouts() {
|
||||
for wrapped in [false, true] {
|
||||
for foreign in [false, true] {
|
||||
let (anchor, mut event) = proof_fixture();
|
||||
if foreign {
|
||||
event.bucket = Arc::from("another-bucket");
|
||||
} else {
|
||||
event.lease = None;
|
||||
}
|
||||
let mut events = VecDeque::with_capacity(8);
|
||||
events.extend(std::iter::repeat_n(event, events.capacity()));
|
||||
if wrapped {
|
||||
let first = events.pop_front().expect("full local proof queue");
|
||||
events.push_back(first);
|
||||
assert!(!events.as_slices().1.is_empty(), "exercise a wrapped producer queue");
|
||||
}
|
||||
let capacity = events.capacity();
|
||||
let storage = events
|
||||
.iter()
|
||||
.map(std::ptr::from_ref)
|
||||
.min()
|
||||
.expect("nonempty local proof storage");
|
||||
let expected = events.clone();
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(events, expected);
|
||||
assert_eq!(anchors, vec![anchor.clone()]);
|
||||
assert_eq!(events.capacity(), capacity);
|
||||
assert_eq!(
|
||||
events.iter().map(std::ptr::from_ref).min(),
|
||||
Some(storage),
|
||||
"a full unmatched queue must reuse its buffer instead of rebuilding it for every bucket"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_consumes_all_exact_anchor_copies_and_preserves_duplicate_proof_order() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let waiting = MrfDurableRepairAnchor {
|
||||
object: Arc::from("waiting"),
|
||||
..anchor.clone()
|
||||
};
|
||||
let foreign = MrfVerifiedRepairEvent {
|
||||
bucket: Arc::from("foreign"),
|
||||
..event.clone()
|
||||
};
|
||||
let unleased = MrfVerifiedRepairEvent {
|
||||
lease: None,
|
||||
..event.clone()
|
||||
};
|
||||
let future = MrfVerifiedRepairEvent {
|
||||
object: Arc::from("future"),
|
||||
..event.clone()
|
||||
};
|
||||
let mut events = VecDeque::from(vec![
|
||||
foreign.clone(),
|
||||
event.clone(),
|
||||
unleased.clone(),
|
||||
event.clone(),
|
||||
future.clone(),
|
||||
]);
|
||||
let first = events.pop_front().expect("foreign proof starts the queue");
|
||||
events.push_back(first);
|
||||
assert!(!events.as_slices().1.is_empty(), "match and retain proofs across the wrap boundary");
|
||||
let mut anchors = vec![anchor.clone(), waiting.clone(), anchor.clone()];
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
2
|
||||
);
|
||||
assert_eq!(anchors, vec![waiting]);
|
||||
assert_eq!(
|
||||
events,
|
||||
VecDeque::from(vec![unleased, event, future, foreign]),
|
||||
"the later duplicate proof remains available after the first proof consumes every exact anchor"
|
||||
);
|
||||
let retained = events.clone();
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(events, retained, "a later consumer must still be able to match the retained proofs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_keeps_every_incomplete_or_mismatched_identity() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let different_purge = MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_identity: [3; 32],
|
||||
};
|
||||
for rejected in [
|
||||
MrfVerifiedRepairEvent {
|
||||
kind: MrfKind::DecodeFailure,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
bucket: Arc::from("foreign"),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
object: Arc::from("other"),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
version_id: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
version_id: Some([8; 16]),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 2,
|
||||
set_index: 2,
|
||||
}),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 1,
|
||||
set_index: 3,
|
||||
}),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
lease: None,
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
lease: Some(MrfIngressLease::new(22)),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..event.clone()
|
||||
},
|
||||
MrfVerifiedRepairEvent {
|
||||
delete_marker_purge: Some(different_purge),
|
||||
..event
|
||||
},
|
||||
] {
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
let mut events = VecDeque::from([rejected.clone()]);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(anchors, vec![anchor.clone()]);
|
||||
assert_eq!(events, VecDeque::from([rejected]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_requires_the_complete_delete_marker_purge_identity() {
|
||||
let (mut anchor, mut event) = proof_fixture();
|
||||
let purge = MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_incarnation_id: anchor.bucket_incarnation_id,
|
||||
marker_identity: [3; 32],
|
||||
};
|
||||
anchor.kind = MrfKind::DeleteMarkerPurge;
|
||||
anchor.delete_marker_purge = Some(purge);
|
||||
event.kind = anchor.kind;
|
||||
event.delete_marker_purge = anchor.delete_marker_purge;
|
||||
for rejected in [
|
||||
None,
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: Uuid::new_v4(),
|
||||
..purge
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
marker_incarnation_id: Uuid::new_v4(),
|
||||
..purge
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
marker_identity: [4; 32],
|
||||
..purge
|
||||
}),
|
||||
] {
|
||||
let rejected = MrfVerifiedRepairEvent {
|
||||
delete_marker_purge: rejected,
|
||||
..event.clone()
|
||||
};
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
let mut events = VecDeque::from([rejected.clone(), event.clone()]);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
1
|
||||
);
|
||||
assert!(anchors.is_empty());
|
||||
assert_eq!(events, VecDeque::from([rejected]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_queue_capacity_follows_the_preconsumption_length_and_releases_on_the_next_empty_call() {
|
||||
let (anchor, event) = proof_fixture();
|
||||
let future_events = (0..3)
|
||||
.map(|index| MrfVerifiedRepairEvent {
|
||||
object: Arc::from(format!("future-{index}")),
|
||||
..event.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut events = VecDeque::with_capacity(64);
|
||||
events.push_back(event);
|
||||
events.extend(future_events.iter().cloned());
|
||||
let previous_event_count = events.len();
|
||||
let mut anchors = vec![anchor.clone()];
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
1
|
||||
);
|
||||
assert_eq!(events.iter().cloned().collect::<Vec<_>>(), future_events);
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity(),
|
||||
"preserve the former queue rebuild's capacity lifetime instead of retaining the producer high water"
|
||||
);
|
||||
let previous_event_count = events.len();
|
||||
let mut anchors = future_events
|
||||
.iter()
|
||||
.map(|event| MrfDurableRepairAnchor {
|
||||
object: event.object.clone(),
|
||||
..anchor.clone()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
3
|
||||
);
|
||||
assert!(events.is_empty());
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
VecDeque::<MrfVerifiedRepairEvent>::with_capacity(previous_event_count).capacity()
|
||||
);
|
||||
assert_eq!(
|
||||
consume_verified_mrf_repair_events_from_queue(&anchor.bucket, &mut anchors, &mut events),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
events.capacity(),
|
||||
0,
|
||||
"the next empty consumption releases backing storage just as a rebuild did"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// 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::*;
|
||||
|
||||
// Compatibility reference from a47649fb60c0492cc0fb02bff28439c5e357f8e4.
|
||||
// Keep this independent of the shared resolver and its canonical helpers.
|
||||
fn original_anchor(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<MrfDurableRepairAnchor> {
|
||||
let lease = intent.lease?;
|
||||
let version_id = intent.version_id.filter(|bytes| *bytes != [0; 16]);
|
||||
let (version_id, scope) = match intent.kind {
|
||||
MrfKind::MetadataCorruption => (None, None),
|
||||
MrfKind::DecodeFailure | MrfKind::PartialWrite | MrfKind::DeleteMarkerPurge => (version_id, intent.scope),
|
||||
};
|
||||
let delete_marker_purge = match intent.kind {
|
||||
MrfKind::DeleteMarkerPurge => intent
|
||||
.delete_marker_purge
|
||||
.as_ref()
|
||||
.filter(|payload| {
|
||||
!payload.bucket_incarnation_id.is_nil()
|
||||
&& payload.marker_incarnation_id == payload.bucket_incarnation_id
|
||||
&& !payload.marker.is_empty()
|
||||
&& payload.marker.len() <= MRF_MAX_DELETE_MARKER_BYTES
|
||||
})
|
||||
.map(|payload| {
|
||||
Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: payload.bucket_incarnation_id,
|
||||
marker_incarnation_id: payload.marker_incarnation_id,
|
||||
marker_identity: payload.marker_identity,
|
||||
})
|
||||
})?,
|
||||
_ if intent.delete_marker_purge.is_none() => None,
|
||||
_ => return None,
|
||||
};
|
||||
let bucket_incarnation_id = delete_marker_purge
|
||||
.map(|identity| identity.bucket_incarnation_id)
|
||||
.unwrap_or(bucket_incarnation_id);
|
||||
if bucket_incarnation_id.is_nil() {
|
||||
return None;
|
||||
}
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: intent.kind,
|
||||
bucket: intent.bucket.clone(),
|
||||
object: intent.object.clone(),
|
||||
version_id,
|
||||
scope,
|
||||
delete_marker_purge,
|
||||
lease,
|
||||
bucket_incarnation_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_repair_identity_matches_the_original_constructor_and_source_filter() {
|
||||
let source = Uuid::from_u128(1);
|
||||
let foreign = Uuid::from_u128(2);
|
||||
let purge = MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: source,
|
||||
marker_incarnation_id: source,
|
||||
marker_identity: [9; 32],
|
||||
marker: Arc::from([7_u8]),
|
||||
};
|
||||
let payloads = [
|
||||
None,
|
||||
Some(purge.clone()),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: foreign,
|
||||
marker_incarnation_id: foreign,
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from([]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker: Arc::from(vec![7; MRF_MAX_DELETE_MARKER_BYTES + 1]),
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
marker_incarnation_id: foreign,
|
||||
..purge.clone()
|
||||
}),
|
||||
Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: Uuid::nil(),
|
||||
marker_incarnation_id: Uuid::nil(),
|
||||
..purge
|
||||
}),
|
||||
];
|
||||
let mut cases = 0;
|
||||
for kind in [
|
||||
MrfKind::DecodeFailure,
|
||||
MrfKind::MetadataCorruption,
|
||||
MrfKind::PartialWrite,
|
||||
MrfKind::DeleteMarkerPurge,
|
||||
] {
|
||||
for lease in [None, Some(MrfIngressLease::new(0)), Some(MrfIngressLease::new(7))] {
|
||||
for version_id in [None, Some([0; 16]), Some([7; 16])] {
|
||||
for scope in [
|
||||
None,
|
||||
Some(MrfScope {
|
||||
pool_index: u32::MAX,
|
||||
set_index: 3,
|
||||
}),
|
||||
] {
|
||||
for input_source in [Uuid::nil(), source, foreign] {
|
||||
for (payload_index, payload) in payloads.iter().enumerate() {
|
||||
let intent = MrfIntent {
|
||||
bucket: Arc::from("source-shape"),
|
||||
object: Arc::from("object"),
|
||||
version_id,
|
||||
kind,
|
||||
delete_marker_purge: payload.clone(),
|
||||
scope,
|
||||
lease,
|
||||
enqueued_at_ms: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
let expected = original_anchor(&intent, input_source);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, input_source),
|
||||
expected,
|
||||
"kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
|
||||
);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::is_eligible_for_source(&intent, input_source),
|
||||
expected
|
||||
.as_ref()
|
||||
.is_some_and(|anchor| anchor.bucket_incarnation_id == input_source),
|
||||
"source eligibility must preserve the exact source: kind={kind:?}, lease={lease:?}, version={version_id:?}, scope={scope:?}, source={input_source}, payload={payload_index}"
|
||||
);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(cases, 1728, "exercise the complete explicit identity-shape matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_identity_preserves_literal_purge_source_lease_and_metadata_boundaries() {
|
||||
let source = Uuid::from_u128(1);
|
||||
let purge_source = Uuid::from_u128(2);
|
||||
let mut intent = MrfIntent {
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
kind: MrfKind::DeleteMarkerPurge,
|
||||
delete_marker_purge: Some(MrfDeleteMarkerPurge {
|
||||
bucket_incarnation_id: purge_source,
|
||||
marker_incarnation_id: purge_source,
|
||||
marker_identity: [9; 32],
|
||||
marker: Arc::from([7_u8]),
|
||||
}),
|
||||
scope: None,
|
||||
lease: Some(MrfIngressLease::new(17)),
|
||||
enqueued_at_ms: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
assert!(
|
||||
!valid_intent_payload(&intent),
|
||||
"constructor compatibility is broader than durable admission"
|
||||
);
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()),
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: MrfKind::DeleteMarkerPurge,
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
scope: None,
|
||||
delete_marker_purge: Some(MrfDeleteMarkerPurgeIdentity {
|
||||
bucket_incarnation_id: purge_source,
|
||||
marker_incarnation_id: purge_source,
|
||||
marker_identity: [9; 32],
|
||||
}),
|
||||
lease: MrfIngressLease::new(17),
|
||||
bucket_incarnation_id: purge_source,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
|
||||
"a purge cannot borrow a conflicting entry source"
|
||||
);
|
||||
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, Uuid::nil()));
|
||||
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
|
||||
intent.lease = None;
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
|
||||
assert!(!MrfDurableRepairAnchor::is_eligible_for_source(&intent, purge_source));
|
||||
intent.lease = Some(MrfIngressLease::new(17));
|
||||
intent
|
||||
.delete_marker_purge
|
||||
.as_mut()
|
||||
.expect("purge fixture")
|
||||
.marker_incarnation_id = Uuid::nil();
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, purge_source).is_none());
|
||||
intent
|
||||
.delete_marker_purge
|
||||
.as_mut()
|
||||
.expect("purge fixture")
|
||||
.bucket_incarnation_id = Uuid::nil();
|
||||
assert!(
|
||||
!MrfDurableRepairAnchor::is_eligible_for_source(&intent, source),
|
||||
"nil marker identity cannot borrow a non-nil source"
|
||||
);
|
||||
intent.kind = MrfKind::PartialWrite;
|
||||
assert!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source).is_none(),
|
||||
"ordinary intents cannot carry a purge payload"
|
||||
);
|
||||
intent.delete_marker_purge = None;
|
||||
intent.kind = MrfKind::MetadataCorruption;
|
||||
intent.version_id = Some([7; 16]);
|
||||
intent.scope = Some(MrfScope {
|
||||
pool_index: 8,
|
||||
set_index: 9,
|
||||
});
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source),
|
||||
Some(MrfDurableRepairAnchor {
|
||||
kind: MrfKind::MetadataCorruption,
|
||||
bucket: Arc::from("literal-bucket"),
|
||||
object: Arc::from("marker"),
|
||||
version_id: None,
|
||||
scope: None,
|
||||
delete_marker_purge: None,
|
||||
lease: MrfIngressLease::new(17),
|
||||
bucket_incarnation_id: source,
|
||||
})
|
||||
);
|
||||
assert!(MrfDurableRepairAnchor::is_eligible_for_source(&intent, source));
|
||||
assert!(MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()).is_none());
|
||||
intent.lease = Some(MrfIngressLease::new(0));
|
||||
assert_eq!(
|
||||
MrfDurableRepairAnchor::from_intent(&intent, source)
|
||||
.expect("preserve the existing opaque lease shape")
|
||||
.lease,
|
||||
MrfIngressLease::new(0)
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,9 @@ pub(crate) const SEED_POOL: usize = 0;
|
||||
const TOPOLOGY_ENV: &[(&str, &str)] = &[
|
||||
("RUSTFS_DURABILITY_MODE", "strict"),
|
||||
("RUSTFS_NEW_BUCKET_DURABILITY_MODE", "strict"),
|
||||
// Match the four-worker CI workload instead of scaling contention with
|
||||
// the host's CPU count; bucket and entry migration remain concurrent.
|
||||
("RUSTFS_DECOMMISSION_ENTRY_CONCURRENCY", "4"),
|
||||
("RUSTFS_SCANNER_ENABLED", "true"),
|
||||
("RUSTFS_HEAL_ENABLED", "true"),
|
||||
("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "true"),
|
||||
|
||||
@@ -53,6 +53,10 @@ const PRE_ACK_REACH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// Cancel and stop are acknowledged before every in-flight entry finishes its
|
||||
/// cleanup; terminal state and placement are polled within this budget.
|
||||
const TERMINAL_TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
/// Recovery includes the fleet-proof retry before migration can resume. A
|
||||
/// healthy CI restart completed after 253 s while still making progress; keep
|
||||
/// this recovery budget below the distributed profile's 720 s process limit.
|
||||
const DECOMMISSION_RESUME_COMPLETE_TIMEOUT: Duration = Duration::from_secs(360);
|
||||
|
||||
fn pools(ids: &[usize]) -> BTreeSet<usize> {
|
||||
ids.iter().copied().collect()
|
||||
@@ -131,6 +135,7 @@ fn timeouts() -> Value {
|
||||
json!({
|
||||
"movement_active_secs": MOVEMENT_ACTIVE_TIMEOUT.as_secs(),
|
||||
"movement_complete_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
|
||||
"decommission_resume_complete_secs": DECOMMISSION_RESUME_COMPLETE_TIMEOUT.as_secs(),
|
||||
"barrier_reach_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
|
||||
"terminal_transition_secs": TERMINAL_TRANSITION_TIMEOUT.as_secs(),
|
||||
})
|
||||
@@ -426,8 +431,13 @@ async fn scanner_heal_topology_decommission_queue_cancel_resume_and_last_pool_gu
|
||||
/// Outcome of a fault case: the worker result and the data verdict are
|
||||
/// reported separately so a fail-closed worker and a lost version never hide
|
||||
/// each other.
|
||||
async fn settle_decommission(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult<Value> {
|
||||
let completion = wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||
async fn settle_decommission(
|
||||
run: &TopologyRun,
|
||||
manifest: &TopologyManifest,
|
||||
label: &str,
|
||||
completion_timeout: Duration,
|
||||
) -> TestResult<Value> {
|
||||
let completion = wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, completion_timeout).await;
|
||||
let allowed = if completion.is_ok() {
|
||||
pools(&TARGET_POOLS)
|
||||
} else {
|
||||
@@ -518,7 +528,7 @@ async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_w
|
||||
run.movement_barrier.disarm()?;
|
||||
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
|
||||
let label = "after the killed leader resumed decommission";
|
||||
let outcome = settle_decommission(&run, &manifest, label).await;
|
||||
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
@@ -573,7 +583,7 @@ async fn scanner_heal_topology_decommission_target_commit_failure_retains_source
|
||||
run.movement_barrier.release_movement_with_io_error()?;
|
||||
|
||||
let label = "after the failed target commit was retried";
|
||||
let outcome = settle_decommission(&run, &manifest, label).await;
|
||||
let outcome = settle_decommission(&run, &manifest, label, MOVEMENT_COMPLETE_TIMEOUT).await;
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
record(
|
||||
&run,
|
||||
@@ -667,7 +677,7 @@ async fn scanner_heal_topology_decommission_kill_after_responsibility_persisted_
|
||||
let label = "after the persisted decommission resumed";
|
||||
let resumed = wait_for_decommission_active(&run.dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(60)).await;
|
||||
let outcome = match resumed {
|
||||
Ok(()) => settle_decommission(&run, &manifest, label).await,
|
||||
Ok(()) => settle_decommission(&run, &manifest, label, DECOMMISSION_RESUME_COMPLETE_TIMEOUT).await,
|
||||
Err(error) => Err(format!("{label}: the persisted decommission did not become active after the restart: {error}").into()),
|
||||
};
|
||||
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
|
||||
|
||||
@@ -279,10 +279,6 @@ harness = false
|
||||
name = "comparison_benchmark"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "rename_data_meta_benchmark"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "single_block_non_inline_benchmark"
|
||||
harness = false
|
||||
@@ -291,5 +287,9 @@ harness = false
|
||||
name = "multipart_read_parts_benchmark"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "local_walk_dir_benchmark"
|
||||
harness = false
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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 criterion::{Criterion, Throughput, criterion_group, criterion_main};
|
||||
use rustfs_filemeta::{MetaCacheEntry, MetacacheReader, test_data::create_real_xlmeta};
|
||||
use std::hint::black_box;
|
||||
|
||||
mod storage_api;
|
||||
use storage_api::local_walk_dir::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
|
||||
|
||||
const OBJECTS: usize = 1000;
|
||||
|
||||
async fn walk_page(disk: &Disk, recursive: bool) -> Vec<MetaCacheEntry> {
|
||||
let opts = WalkDirOptions {
|
||||
bucket: "bench-bucket".to_string(),
|
||||
base_dir: "prefix".to_string(),
|
||||
recursive,
|
||||
limit: i32::try_from(OBJECTS).expect("page limit fits"),
|
||||
..Default::default()
|
||||
};
|
||||
let (reader, mut writer) = tokio::io::duplex(4096);
|
||||
let produce = async move { disk.walk_dir(opts, &mut writer).await };
|
||||
let consume = async { MetacacheReader::new(reader).read_all().await.expect("decode listing stream") };
|
||||
let (result, entries) = tokio::join!(produce, consume);
|
||||
result.expect("production walk_dir must succeed");
|
||||
entries
|
||||
}
|
||||
|
||||
fn bench_local_walk_dir(c: &mut Criterion) {
|
||||
let root = tempfile::tempdir().expect("create benchmark disk");
|
||||
let metadata = create_real_xlmeta().expect("create object metadata");
|
||||
let decoded = rustfs_filemeta::FileMeta::load(&metadata).expect("decode fixture");
|
||||
let data_dirs = decoded.get_data_dirs().expect("fixture data directories");
|
||||
for i in 0..OBJECTS {
|
||||
let object_dir = root.path().join("bench-bucket/prefix").join(format!("object-{i:04}"));
|
||||
std::fs::create_dir_all(&object_dir).expect("create object directory");
|
||||
for data_dir in data_dirs.iter().flatten() {
|
||||
std::fs::create_dir_all(object_dir.join(data_dir.to_string())).expect("create data directory");
|
||||
}
|
||||
std::fs::write(object_dir.join("xl.meta"), &metadata).expect("write object metadata");
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(4)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build benchmark runtime");
|
||||
let endpoint = Endpoint::try_from(root.path().to_str().expect("UTF-8 disk path")).expect("disk endpoint");
|
||||
let disk = runtime
|
||||
.block_on(new_disk(
|
||||
&endpoint,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
))
|
||||
.expect("open local disk");
|
||||
let mut group = c.benchmark_group("local_walk_dir");
|
||||
group.throughput(Throughput::Elements(u64::try_from(OBJECTS).expect("object count fits")));
|
||||
for (name, recursive) in [("recursive_1000_objects", true), ("non_recursive_1000_objects", false)] {
|
||||
let entries = runtime.block_on(walk_page(&disk, recursive));
|
||||
let objects: Vec<_> = entries.iter().filter(|entry| !entry.metadata.is_empty()).collect();
|
||||
assert_eq!(objects.len(), OBJECTS);
|
||||
for (i, entry) in objects.iter().enumerate() {
|
||||
assert_eq!(entry.name, format!("prefix/object-{i:04}"));
|
||||
}
|
||||
group.bench_function(name, |b| {
|
||||
b.iter(|| black_box(runtime.block_on(walk_page(&disk, recursive))));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_local_walk_dir);
|
||||
criterion_main!(benches);
|
||||
@@ -33,3 +33,7 @@ pub(crate) mod single_block_non_inline {
|
||||
pub(crate) mod multipart {
|
||||
pub(crate) use rustfs_ecstore::api::disk::{DiskAPI, DiskOption, Endpoint, new_disk};
|
||||
}
|
||||
|
||||
pub(crate) mod local_walk_dir {
|
||||
pub(crate) use rustfs_ecstore::api::disk::{Disk, DiskAPI, DiskOption, Endpoint, WalkDirOptions, new_disk};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub mod diagnostic {
|
||||
pub use crate::cluster::rpc::runtime_sources::{
|
||||
current_read_version_entry_source, observe_read_version_context_boundary, with_read_version_entry,
|
||||
with_read_version_spawn_context,
|
||||
current_read_version_entry_source, observe_read_version_context_boundary, with_captured_read_version_context,
|
||||
with_read_version_entry, with_read_version_spawn_context,
|
||||
};
|
||||
pub use rustfs_io_metrics::internode_metrics::{ReadVersionContextBoundary, ReadVersionEntrySource};
|
||||
}
|
||||
|
||||
@@ -3044,14 +3044,7 @@ fn spawn_tier_free_version_recovery_once_with_options(
|
||||
move |bucket_marker, object_marker, recovery_cancel| {
|
||||
let api = Arc::clone(&api);
|
||||
async move {
|
||||
recover_tier_free_versions_with_cancel(
|
||||
api,
|
||||
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
|
||||
bucket_marker,
|
||||
object_marker,
|
||||
recovery_cancel,
|
||||
)
|
||||
.await
|
||||
recover_configured_tier_free_versions(api, bucket_marker, object_marker, recovery_cancel).await
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -3063,6 +3056,39 @@ fn spawn_tier_free_version_recovery_once_with_options(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn recover_configured_tier_free_versions(
|
||||
api: Arc<ECStore>,
|
||||
bucket_marker: Option<String>,
|
||||
object_marker: Option<String>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> crate::error::Result<FreeVersionRecoveryStats> {
|
||||
let tier_config_mgr = api.tier_config_mgr();
|
||||
let has_tiers = select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version recovery cancelled").into());
|
||||
}
|
||||
manager = tier_config_mgr.read() => !manager.empty(),
|
||||
};
|
||||
// Startup can launch recovery before tier configuration is loaded. Keep
|
||||
// polling, but release the manager lock before any namespace I/O.
|
||||
if !has_tiers {
|
||||
return Ok(FreeVersionRecoveryStats {
|
||||
scanned: 0,
|
||||
enqueued: 0,
|
||||
failed: 0,
|
||||
next_bucket_marker: None,
|
||||
next_object_marker: None,
|
||||
scanned_entries: 0,
|
||||
buckets_scanned: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
recover_tier_free_versions_with_cancel(api, DEFAULT_FREE_VERSION_RECOVERY_LIMIT, bucket_marker, object_marker, cancel_token)
|
||||
.await
|
||||
}
|
||||
|
||||
fn tier_free_version_recovery_leader_retry_delay() -> StdDuration {
|
||||
StdDuration::from_secs(rand::rng().random_range(15..=30))
|
||||
}
|
||||
@@ -14732,6 +14758,207 @@ mod tests {
|
||||
StdDuration::from_millis(10)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_without_tiers_skips_listing_and_resets_cursor() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let manager = ecstore.tier_config_mgr();
|
||||
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
|
||||
let bucket = format!("recovery-no-tiers-{}", Uuid::new_v4());
|
||||
let object = "free-version";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
|
||||
let metadata_path = disk_paths[0].join(&bucket).join(object).join(STORAGE_FORMAT_FILE);
|
||||
let metadata_before = fs::read(&metadata_path).await.expect("seeded metadata should be readable");
|
||||
|
||||
let list_started = Arc::new(tokio::sync::Notify::new());
|
||||
let list_wait = set_recovery_bucket_list_wait_hook(Arc::clone(&list_started));
|
||||
let walk_calls = Arc::new(AtomicUsize::new(0));
|
||||
let counted_walks = Arc::clone(&walk_calls);
|
||||
let walk_hook = set_recovery_walk_test_hook(move |_| {
|
||||
counted_walks.fetch_add(1, Ordering::SeqCst);
|
||||
None
|
||||
});
|
||||
let mut schedule = super::TierFreeVersionRecoverySchedule::default();
|
||||
schedule.record_success(&free_version_recovery_stats(1, 0, true));
|
||||
let stats = tokio::select! {
|
||||
biased;
|
||||
_ = list_started.notified() => panic!("empty tier configuration must not list buckets"),
|
||||
result = super::recover_configured_tier_free_versions(
|
||||
Arc::clone(&ecstore),
|
||||
schedule.bucket_marker.clone(),
|
||||
schedule.object_marker.clone(),
|
||||
CancellationToken::new(),
|
||||
) => result.expect("empty tier configuration should be an idle recovery result"),
|
||||
};
|
||||
assert_eq!(
|
||||
stats,
|
||||
FreeVersionRecoveryStats {
|
||||
scanned: 0,
|
||||
enqueued: 0,
|
||||
failed: 0,
|
||||
next_bucket_marker: None,
|
||||
next_object_marker: None,
|
||||
scanned_entries: 0,
|
||||
buckets_scanned: 0,
|
||||
truncated: false,
|
||||
}
|
||||
);
|
||||
schedule.record_success(&stats);
|
||||
assert!(schedule.bucket_marker.is_none());
|
||||
assert!(schedule.object_marker.is_none());
|
||||
assert!(!schedule.follow_up_sweep);
|
||||
assert_eq!(walk_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(matches!(
|
||||
recovery_rx.lock().await.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read(&metadata_path).await.expect("paused recovery must retain metadata"),
|
||||
metadata_before
|
||||
);
|
||||
drop(list_wait);
|
||||
drop(walk_hook);
|
||||
|
||||
remove_seeded_free_version(&disk_paths, &bucket, object).await;
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("test bucket should be removed");
|
||||
manager.write().await.tiers = saved_tiers;
|
||||
reset_runtime_expiry_state(&ecstore).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_tier_snapshot_wait_is_cancellable() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let manager = ecstore.tier_config_mgr();
|
||||
let _write_guard = manager.write().await;
|
||||
let cancel = CancellationToken::new();
|
||||
let recovery = super::recover_configured_tier_free_versions(ecstore, None, None, cancel.clone());
|
||||
tokio::pin!(recovery);
|
||||
assert!(
|
||||
futures::poll!(&mut recovery).is_pending(),
|
||||
"recovery should wait for the tier manager lock"
|
||||
);
|
||||
cancel.cancel();
|
||||
let err = recovery
|
||||
.await
|
||||
.expect_err("cancellation must not wait for the tier manager writer");
|
||||
assert!(matches!(err, crate::error::Error::Io(ref err) if err.kind() == std::io::ErrorKind::Interrupted));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_loop_observes_tier_configuration_changes() {
|
||||
let (disk_paths, ecstore) = setup_test_env().await;
|
||||
let manager = ecstore.tier_config_mgr();
|
||||
let saved_tiers = std::mem::take(&mut manager.write().await.tiers);
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let recovery_rx = Arc::clone(&runtime_state.read().await.tasks_rx[0]);
|
||||
let bucket = format!("recovery-dynamic-tier-{}", Uuid::new_v4());
|
||||
let object = "free-version";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
seed_recoverable_free_version(&disk_paths, &bucket, object, None, None).await;
|
||||
|
||||
let (page_tx, mut page_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let cancel = CancellationToken::new();
|
||||
let loop_cancel = cancel.clone();
|
||||
let recovery_store = Arc::clone(&ecstore);
|
||||
let worker = tokio::spawn(async move {
|
||||
run_tier_free_version_recovery_loop(
|
||||
loop_cancel,
|
||||
runtime_state,
|
||||
std::convert::identity,
|
||||
move |bucket, object, cancel| {
|
||||
let api = Arc::clone(&recovery_store);
|
||||
let page_tx = page_tx.clone();
|
||||
async move {
|
||||
let stats =
|
||||
super::recover_configured_tier_free_versions(api, bucket.clone(), object.clone(), cancel).await?;
|
||||
page_tx
|
||||
.send((bucket, object, stats.clone()))
|
||||
.expect("test should observe each recovery result");
|
||||
Ok(stats)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
let (_, _, idle) = page_rx
|
||||
.recv()
|
||||
.await
|
||||
.expect("worker should check the initially empty configuration");
|
||||
assert_eq!(idle.buckets_scanned, 0);
|
||||
assert!(matches!(
|
||||
recovery_rx.lock().await.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
|
||||
crate::services::tier::test_util::register_mock_tier(&manager, &tier).await;
|
||||
tokio::time::pause();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
|
||||
tokio::time::resume();
|
||||
let (bucket_marker, object_marker, active) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
|
||||
.await
|
||||
.expect("new tier should enable the next recovery page")
|
||||
.expect("worker should remain running");
|
||||
assert!(bucket_marker.is_none());
|
||||
assert!(object_marker.is_none());
|
||||
assert_eq!(active.enqueued, 1);
|
||||
let task = recovery_rx
|
||||
.lock()
|
||||
.await
|
||||
.try_recv()
|
||||
.expect("new tier should enable discovery")
|
||||
.expect("queue item should exist");
|
||||
let recovered = task
|
||||
.as_any()
|
||||
.downcast_ref::<FreeVersionTask>()
|
||||
.expect("seeded free version should be queued");
|
||||
assert_eq!(recovered.0.bucket, bucket);
|
||||
assert_eq!(recovered.0.name, object);
|
||||
|
||||
// Simulate a historical or temporarily missing configuration without
|
||||
// bypassing the admin removal path's persisted-reference proof.
|
||||
let configured_tiers = std::mem::take(&mut manager.write().await.tiers);
|
||||
tokio::time::pause();
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL).await;
|
||||
tokio::time::resume();
|
||||
let (_, _, idle_again) = tokio::time::timeout(StdDuration::from_secs(30), page_rx.recv())
|
||||
.await
|
||||
.expect("empty configuration should still produce an idle check")
|
||||
.expect("worker should remain running");
|
||||
assert_eq!(idle_again.buckets_scanned, 0);
|
||||
assert_eq!(idle_again.enqueued, 0);
|
||||
assert!(matches!(
|
||||
recovery_rx.lock().await.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
cancel.cancel();
|
||||
worker.await.expect("worker should exit on cancellation");
|
||||
manager.write().await.tiers = configured_tiers;
|
||||
manager
|
||||
.write()
|
||||
.await
|
||||
.remove(&tier, true)
|
||||
.await
|
||||
.expect("mock tier should be removed");
|
||||
manager.write().await.tiers = saved_tiers;
|
||||
remove_seeded_free_version(&disk_paths, &bucket, object).await;
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
.await
|
||||
.expect("test bucket should be removed");
|
||||
reset_runtime_expiry_state(&ecstore).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_production_entrypoint_enqueues_seeded_item() {
|
||||
@@ -14740,6 +14967,8 @@ mod tests {
|
||||
setup_test_env().await
|
||||
})
|
||||
.await;
|
||||
let tier = format!("RECOVERY{}", Uuid::new_v4().simple()).to_uppercase();
|
||||
crate::services::tier::test_util::register_mock_tier(&ecstore.tier_config_mgr(), &tier).await;
|
||||
let runtime_state = install_unconsumed_runtime_expiry_worker(&ecstore, 1).await;
|
||||
let recovery_rx = {
|
||||
let state = runtime_state.read().await;
|
||||
@@ -14775,6 +15004,13 @@ mod tests {
|
||||
.await
|
||||
.expect_err("aborted production recovery task should report cancellation");
|
||||
assert!(join_error.is_cancelled());
|
||||
ecstore
|
||||
.tier_config_mgr()
|
||||
.write()
|
||||
.await
|
||||
.remove(&tier, true)
|
||||
.await
|
||||
.expect("mock tier should be removed");
|
||||
remove_seeded_free_version(&disk_paths, &bucket, object).await;
|
||||
ecstore
|
||||
.delete_bucket(&bucket, &DeleteBucketOptions::default())
|
||||
|
||||
@@ -322,6 +322,19 @@ where
|
||||
with_read_version_diagnostic_context(context, boundary, future)
|
||||
}
|
||||
|
||||
/// Capture before spawning without inventing an entry for a generic task.
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub fn with_captured_read_version_context<F>(
|
||||
boundary: ReadVersionContextBoundary,
|
||||
future: F,
|
||||
) -> impl std::future::Future<Output = F::Output>
|
||||
where
|
||||
F: std::future::Future,
|
||||
{
|
||||
let context = ReadVersionDiagnosticContext::capture();
|
||||
with_read_version_diagnostic_context(context, boundary, Box::pin(future))
|
||||
}
|
||||
|
||||
/// Observe a named entry even when its scope retains an earlier source.
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub fn observe_read_version_context_boundary(boundary: ReadVersionContextBoundary) {
|
||||
@@ -1337,4 +1350,65 @@ mod tests {
|
||||
assert_eq!(caller, Some(ReadVersionRequestCaller::Peer));
|
||||
assert_eq!(current_read_version_request_caller(), None);
|
||||
}
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[tokio::test]
|
||||
async fn captured_context_keeps_existing_values_without_creating_an_entry() {
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation, with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
let operation = request.in_scope(|| {
|
||||
observe_s3_http_operation(rustfs_s3_ops::S3Operation::PutObject);
|
||||
current_s3_http_operation_context().expect("captured PUT operation")
|
||||
});
|
||||
for inherited in [false, true] {
|
||||
let make_future = || {
|
||||
with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
|
||||
tokio::task::yield_now().await;
|
||||
(
|
||||
current_read_version_request_caller(),
|
||||
current_read_version_entry_source(),
|
||||
current_s3_http_operation_name(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let captured = if inherited {
|
||||
with_s3_http_operation_context(Some(operation), async {
|
||||
with_read_version_request_caller(ReadVersionRequestCaller::Object, async {
|
||||
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async { make_future() }).await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
make_future()
|
||||
};
|
||||
assert_eq!(current_read_version_request_caller(), None);
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
let observed = tokio::spawn(captured).await.expect("captured child joins");
|
||||
let expected = if inherited {
|
||||
(
|
||||
Some(ReadVersionRequestCaller::Object),
|
||||
Some(ReadVersionEntrySource::TierConfigReload),
|
||||
Some("s3:PutObject"),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
assert_eq!(observed, expected);
|
||||
assert_eq!(current_s3_http_operation_name(), None);
|
||||
}
|
||||
|
||||
let polled = std::sync::atomic::AtomicBool::new(false);
|
||||
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
|
||||
let unpolled = with_captured_read_version_context(ReadVersionContextBoundary::BackgroundTask, async {
|
||||
polled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
});
|
||||
drop(unpolled);
|
||||
})
|
||||
.await;
|
||||
assert!(!polled.load(std::sync::atomic::Ordering::Relaxed));
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16562,6 +16562,16 @@ impl ECStore {
|
||||
&self,
|
||||
source_pool_index: usize,
|
||||
source_bytes: usize,
|
||||
) -> Result<()> {
|
||||
self.prepare_scanner_pause_backlog_retirement_targets_for_test(&[source_pool_index], source_bytes)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
pub async fn prepare_scanner_pause_backlog_retirement_targets_for_test(
|
||||
&self,
|
||||
source_pool_indices: &[usize],
|
||||
source_bytes: usize,
|
||||
) -> Result<()> {
|
||||
let source_bytes = source_bytes.max(1);
|
||||
let total = source_bytes.saturating_mul(8).saturating_add(64 * 1024);
|
||||
@@ -16570,14 +16580,18 @@ impl ECStore {
|
||||
DecommissionPoolCapacityInfo::for_test(
|
||||
pool_index,
|
||||
DecommissionErasureLayout { data: 1, parity: 0 },
|
||||
if pool_index == source_pool_index { 0 } else { total },
|
||||
if source_pool_indices.contains(&pool_index) { 0 } else { total },
|
||||
total,
|
||||
if pool_index == source_pool_index { source_bytes } else { 0 },
|
||||
if source_pool_indices.contains(&pool_index) {
|
||||
source_bytes
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
set_decommission_capacity_info_overrides_for_test(self.id, vec![capacities]);
|
||||
self.save_current_pool_meta_for_decommission_start(&[source_pool_index], Vec::new())
|
||||
self.save_current_pool_meta_for_decommission_start(source_pool_indices, Vec::new())
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
@@ -16679,8 +16693,8 @@ impl ECStore {
|
||||
capacity_owner: Option<DecommissionCapacityOwner>,
|
||||
) -> Result<DecommissionEntryAttemptOutcome> {
|
||||
use data_movement::scanner_backlog::{
|
||||
SCANNER_PAUSE_BACKLOG_PATH, persist_native_scanner_pause_backlog_replica, plan_scanner_pause_backlog_retirement,
|
||||
read_scanner_pause_backlog_retirement_replicas,
|
||||
SCANNER_PAUSE_BACKLOG_PATH, ScannerPauseBacklogRetirementPhase, persist_native_scanner_pause_backlog_replica,
|
||||
plan_scanner_pause_backlog_retirement, read_scanner_pause_backlog_retirement_replicas,
|
||||
};
|
||||
|
||||
if expected.versions.is_empty() && expected.free_versions.is_empty() {
|
||||
@@ -16735,19 +16749,32 @@ impl ECStore {
|
||||
.pools
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(pool_index, _)| *pool_index == idx || snapshot.scanner_pause_backlog_pool_writable(*pool_index))
|
||||
.flat_map(|(_, pool)| pool.disk_set.iter().cloned())
|
||||
// Queued pools can still carry members of the last native commit.
|
||||
// Read their proof without admitting them as handoff destinations.
|
||||
.filter(|(pool_index, _)| {
|
||||
*pool_index == idx
|
||||
|| snapshot
|
||||
.pools
|
||||
.get(*pool_index)
|
||||
.is_some_and(|pool| !pool.decommission.as_ref().is_some_and(|info| info.complete))
|
||||
})
|
||||
.flat_map(|(pool_index, pool)| {
|
||||
let writable = snapshot.scanner_pause_backlog_pool_writable(pool_index);
|
||||
pool.disk_set.iter().map(move |set| (Arc::clone(set), writable))
|
||||
})
|
||||
.collect();
|
||||
let mut replicas = read_scanner_pause_backlog_retirement_replicas(idx, set.set_index, sets.clone()).await?;
|
||||
if let Some(plan) = plan_scanner_pause_backlog_retirement(idx, &replicas)? {
|
||||
if let Some(plan) =
|
||||
plan_scanner_pause_backlog_retirement(idx, &replicas, ScannerPauseBacklogRetirementPhase::InitialRead)?
|
||||
{
|
||||
let expected_stable_record = plan.stable_record.clone();
|
||||
let mut phases = Vec::with_capacity(3);
|
||||
if let Some(record) = plan.seed_record {
|
||||
phases.push(("seed", record));
|
||||
phases.push(("seed", ScannerPauseBacklogRetirementPhase::SeedReadback, record));
|
||||
}
|
||||
phases.push(("commit", plan.commit_record));
|
||||
phases.push(("stabilize", plan.stable_record));
|
||||
for (phase, record) in phases {
|
||||
phases.push(("commit", ScannerPauseBacklogRetirementPhase::CommitReadback, plan.commit_record));
|
||||
phases.push(("stabilize", ScannerPauseBacklogRetirementPhase::StabilizeReadback, plan.stable_record));
|
||||
for (phase, readback_phase, record) in phases {
|
||||
if object_fence.is_lock_lost()
|
||||
|| pool_meta_guard.is_lock_lost()
|
||||
|| !reservation.admits_owner(owner, OffsetDateTime::now_utc())
|
||||
@@ -16756,28 +16783,37 @@ impl ECStore {
|
||||
"scanner pause backlog retirement fence expired during native repair",
|
||||
));
|
||||
}
|
||||
for read in replicas.iter().filter(|read| read.replica.pool_index != idx) {
|
||||
for read in replicas
|
||||
.iter()
|
||||
.filter(|read| read.replica.pool_index != idx && read.replica.writable)
|
||||
{
|
||||
ensure_external_decommission_target_admission(
|
||||
&snapshot,
|
||||
read.replica.pool_index,
|
||||
DecommissionCapacityAdmission::ScannerBacklog,
|
||||
)?;
|
||||
}
|
||||
let results = join_all(replicas.iter().filter(|read| read.replica.pool_index != idx).map(|read| {
|
||||
let target = Arc::clone(&self.pools[read.replica.pool_index].disk_set[read.replica.set_index]);
|
||||
let record = record.clone();
|
||||
let object_fence = &object_fence;
|
||||
let pool_meta_guard = &pool_meta_guard;
|
||||
async move {
|
||||
let mut opts = ObjectOptions {
|
||||
no_lock: self.pools[0].disk_set[0].shares_namespace_lock_domain(&target).await,
|
||||
..Default::default()
|
||||
};
|
||||
object_fence.add_namespace_lock_fence(&mut opts);
|
||||
opts.add_namespace_lock_guard(pool_meta_guard);
|
||||
persist_native_scanner_pause_backlog_replica(target, record, read.preconditions(), opts, phase).await
|
||||
}
|
||||
}))
|
||||
let results = join_all(
|
||||
replicas
|
||||
.iter()
|
||||
.filter(|read| read.replica.pool_index != idx && read.replica.writable)
|
||||
.map(|read| {
|
||||
let target = Arc::clone(&self.pools[read.replica.pool_index].disk_set[read.replica.set_index]);
|
||||
let record = record.clone();
|
||||
let object_fence = &object_fence;
|
||||
let pool_meta_guard = &pool_meta_guard;
|
||||
async move {
|
||||
let mut opts = ObjectOptions {
|
||||
no_lock: self.pools[0].disk_set[0].shares_namespace_lock_domain(&target).await,
|
||||
..Default::default()
|
||||
};
|
||||
object_fence.add_namespace_lock_fence(&mut opts);
|
||||
opts.add_namespace_lock_guard(pool_meta_guard);
|
||||
persist_native_scanner_pause_backlog_replica(target, record, read.preconditions(), opts, phase)
|
||||
.await
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Every dispatched native CAS has finished before an error can
|
||||
// release the owned task's object and durable membership fences.
|
||||
@@ -16787,20 +16823,20 @@ impl ECStore {
|
||||
replicas = read_scanner_pause_backlog_retirement_replicas(idx, set.set_index, sets.clone()).await?;
|
||||
if replicas
|
||||
.iter()
|
||||
.filter(|read| read.replica.pool_index != idx)
|
||||
.filter(|read| read.replica.pool_index != idx && read.replica.writable)
|
||||
.any(|read| read.replica.data.as_deref() != Some(record.as_slice()))
|
||||
{
|
||||
return Err(Error::other(
|
||||
"scanner pause backlog native repair did not persist every surviving replica",
|
||||
));
|
||||
}
|
||||
if let Some(next) = plan_scanner_pause_backlog_retirement(idx, &replicas)?
|
||||
if let Some(next) = plan_scanner_pause_backlog_retirement(idx, &replicas, readback_phase)?
|
||||
&& next.stable_record != expected_stable_record
|
||||
{
|
||||
return Err(Error::other("scanner pause backlog native authority changed during repair"));
|
||||
}
|
||||
}
|
||||
if plan_scanner_pause_backlog_retirement(idx, &replicas)?.is_some() {
|
||||
if plan_scanner_pause_backlog_retirement(idx, &replicas, ScannerPauseBacklogRetirementPhase::FinalCheck)?.is_some() {
|
||||
return Err(Error::other("scanner pause backlog native repair is not fully committed and stable"));
|
||||
}
|
||||
}
|
||||
@@ -27386,40 +27422,45 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn decommission_scanner_backlog_handoff_retry_is_typed_and_bounded() {
|
||||
use data_movement::scanner_backlog::ScannerPauseBacklogRetirementError;
|
||||
use data_movement::scanner_backlog::{ScannerPauseBacklogRetirementError, ScannerPauseBacklogRetirementPhase};
|
||||
|
||||
let handoff = data_movement::data_movement_context_error(
|
||||
"scanner pause backlog retirement planner failed: competing proofs".to_string(),
|
||||
ScannerPauseBacklogRetirementError::HandoffConflict {
|
||||
reason: "competing maximum-membership commit proofs".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(is_decommission_scanner_backlog_handoff_conflict(&handoff));
|
||||
assert_eq!(
|
||||
decommission_scanner_backlog_retry_kind(&handoff, DECOMMISSION_SCANNER_BACKLOG_HANDOFF_MAX_ATTEMPTS - 1),
|
||||
Some(DecommissionScannerBacklogRetryKind::HandoffConflict)
|
||||
);
|
||||
assert_eq!(
|
||||
decommission_scanner_backlog_retry_kind(&handoff, DECOMMISSION_SCANNER_BACKLOG_HANDOFF_MAX_ATTEMPTS),
|
||||
None,
|
||||
"a divergent handoff must not retry forever"
|
||||
);
|
||||
|
||||
for non_retryable in [
|
||||
ScannerPauseBacklogRetirementError::AuthorityConflict {
|
||||
reason: "larger source authority".to_string(),
|
||||
},
|
||||
ScannerPauseBacklogRetirementError::InvalidRecord {
|
||||
reason: "unread member".to_string(),
|
||||
},
|
||||
for phase in [
|
||||
ScannerPauseBacklogRetirementPhase::InitialRead,
|
||||
ScannerPauseBacklogRetirementPhase::SeedReadback,
|
||||
ScannerPauseBacklogRetirementPhase::CommitReadback,
|
||||
ScannerPauseBacklogRetirementPhase::StabilizeReadback,
|
||||
ScannerPauseBacklogRetirementPhase::FinalCheck,
|
||||
] {
|
||||
let err = data_movement::data_movement_context_error("planner failed".to_string(), non_retryable);
|
||||
assert!(!is_decommission_scanner_backlog_handoff_conflict(&err));
|
||||
let handoff = phase.planner_error(ScannerPauseBacklogRetirementError::HandoffConflict {
|
||||
reason: "competing maximum-membership commit proofs".to_string(),
|
||||
});
|
||||
assert!(is_decommission_scanner_backlog_handoff_conflict(&handoff));
|
||||
assert_eq!(
|
||||
decommission_scanner_backlog_retry_kind(&err, 0),
|
||||
None,
|
||||
"only a handoff conflict may be retried"
|
||||
decommission_scanner_backlog_retry_kind(&handoff, DECOMMISSION_SCANNER_BACKLOG_HANDOFF_MAX_ATTEMPTS - 1),
|
||||
Some(DecommissionScannerBacklogRetryKind::HandoffConflict)
|
||||
);
|
||||
assert_eq!(
|
||||
decommission_scanner_backlog_retry_kind(&handoff, DECOMMISSION_SCANNER_BACKLOG_HANDOFF_MAX_ATTEMPTS),
|
||||
None,
|
||||
"a divergent handoff must not retry forever"
|
||||
);
|
||||
|
||||
for non_retryable in [
|
||||
ScannerPauseBacklogRetirementError::AuthorityConflict {
|
||||
reason: "larger source authority".to_string(),
|
||||
},
|
||||
ScannerPauseBacklogRetirementError::InvalidRecord {
|
||||
reason: "unread member".to_string(),
|
||||
},
|
||||
] {
|
||||
let err = phase.planner_error(non_retryable);
|
||||
assert!(!is_decommission_scanner_backlog_handoff_conflict(&err));
|
||||
assert_eq!(
|
||||
decommission_scanner_backlog_retry_kind(&err, 0),
|
||||
None,
|
||||
"only a handoff conflict may be retried"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,43 @@ impl ScannerPauseBacklogRetirementError {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same native planner runs before repair and after each durable phase.
|
||||
/// Keep this context bounded without exposing replica payloads or revisions.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum ScannerPauseBacklogRetirementPhase {
|
||||
InitialRead,
|
||||
SeedReadback,
|
||||
CommitReadback,
|
||||
StabilizeReadback,
|
||||
FinalCheck,
|
||||
}
|
||||
|
||||
impl ScannerPauseBacklogRetirementPhase {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::InitialRead => "initial_read",
|
||||
Self::SeedReadback => "seed_readback",
|
||||
Self::CommitReadback => "commit_readback",
|
||||
Self::StabilizeReadback => "stabilize_readback",
|
||||
Self::FinalCheck => "final_check",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn planner_error(self, err: ScannerPauseBacklogRetirementError) -> Error {
|
||||
data_movement_context_error(
|
||||
format!("scanner pause backlog retirement planner failed during {}: {err}", self.as_str()),
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded, storage-fenced native replica. Only a confirmed missing object
|
||||
/// has no payload; read failures never enter the Scanner verifier.
|
||||
pub struct ScannerPauseBacklogRetirementReplica {
|
||||
pub pool_index: usize,
|
||||
pub set_index: usize,
|
||||
/// False for retiring members retained only to verify the old commit proof.
|
||||
pub writable: bool,
|
||||
pub data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -104,10 +136,11 @@ impl ScannerPauseBacklogRetirementRead {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetirementRead> {
|
||||
async fn read_replica((set, writable): (Arc<SetDisks>, bool)) -> Result<ScannerPauseBacklogRetirementRead> {
|
||||
let mut replica = ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: set.pool_index,
|
||||
set_index: set.set_index,
|
||||
writable,
|
||||
data: None,
|
||||
};
|
||||
let opts = ObjectOptions {
|
||||
@@ -156,7 +189,7 @@ async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetiremen
|
||||
pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
|
||||
source_pool_index: usize,
|
||||
source_set_index: usize,
|
||||
sets: Vec<Arc<SetDisks>>,
|
||||
sets: Vec<(Arc<SetDisks>, bool)>,
|
||||
) -> Result<Vec<ScannerPauseBacklogRetirementRead>> {
|
||||
let replicas = join_all(sets.into_iter().map(read_replica))
|
||||
.await
|
||||
@@ -170,23 +203,30 @@ pub(crate) async fn read_scanner_pause_backlog_retirement_replicas(
|
||||
Ok(replicas)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("scanner pause backlog native retirement planner is unavailable during {phase}")]
|
||||
struct ScannerPauseBacklogPlannerUnavailable {
|
||||
phase: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) fn plan_scanner_pause_backlog_retirement(
|
||||
source_pool_index: usize,
|
||||
replicas: &[ScannerPauseBacklogRetirementRead],
|
||||
phase: ScannerPauseBacklogRetirementPhase,
|
||||
) -> Result<Option<ScannerPauseBacklogRetirementPlan>> {
|
||||
let planner = RETIREMENT_PLANNER
|
||||
.get()
|
||||
.ok_or_else(|| Error::other("scanner pause backlog native retirement planner is unavailable"))?;
|
||||
.ok_or_else(|| Error::other(ScannerPauseBacklogPlannerUnavailable { phase: phase.as_str() }))?;
|
||||
let snapshots = replicas
|
||||
.iter()
|
||||
.map(|read| ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: read.replica.pool_index,
|
||||
set_index: read.replica.set_index,
|
||||
writable: read.replica.writable,
|
||||
data: read.replica.data.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
planner(source_pool_index, &snapshots)
|
||||
.map_err(|err| data_movement_context_error(format!("scanner pause backlog retirement planner failed: {err}"), err))
|
||||
planner(source_pool_index, &snapshots).map_err(|err| phase.planner_error(err))
|
||||
}
|
||||
|
||||
/// The native writer and retirement handoff use the same conditional, full-tail
|
||||
@@ -315,3 +355,138 @@ pub mod test_util {
|
||||
Ok(Some(fault))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data_movement::data_movement_stage_source_as;
|
||||
|
||||
fn failing_planner(
|
||||
source_pool_index: usize,
|
||||
replicas: &[ScannerPauseBacklogRetirementReplica],
|
||||
) -> std::result::Result<Option<ScannerPauseBacklogRetirementPlan>, ScannerPauseBacklogRetirementError> {
|
||||
assert!(replicas.is_empty(), "the callback fixture needs no storage replicas");
|
||||
Err(match source_pool_index {
|
||||
0 => ScannerPauseBacklogRetirementError::HandoffConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
1 => ScannerPauseBacklogRetirementError::AuthorityConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
2 => ScannerPauseBacklogRetirementError::InvalidRecord {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
_ => panic!("unexpected planner fixture source {source_pool_index}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retirement_planner_phase_context_preserves_every_typed_error() {
|
||||
const TEST_NAME: &str =
|
||||
"data_movement::scanner_backlog::tests::retirement_planner_phase_context_preserves_every_typed_error";
|
||||
const CHILD: &str = "RUSTFS_TEST_RETIREMENT_PLANNER_PHASE_CHILD";
|
||||
if std::env::var(CHILD).ok().as_deref() != Some(TEST_NAME) {
|
||||
// Registration is process-global and must not replace another test's planner.
|
||||
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args(["--exact", TEST_NAME, "--nocapture", "--color", "never"])
|
||||
.env(CHILD, TEST_NAME)
|
||||
.output()
|
||||
.expect("start isolated retirement planner test");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let one_passed = stdout
|
||||
.lines()
|
||||
.any(|line| line.starts_with("test result: ok. 1 passed; 0 failed;"));
|
||||
assert!(
|
||||
output.status.success() && one_passed,
|
||||
"isolated planner regression must execute exactly one passing test: {stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let phases = [
|
||||
(ScannerPauseBacklogRetirementPhase::InitialRead, "initial_read"),
|
||||
(ScannerPauseBacklogRetirementPhase::SeedReadback, "seed_readback"),
|
||||
(ScannerPauseBacklogRetirementPhase::CommitReadback, "commit_readback"),
|
||||
(ScannerPauseBacklogRetirementPhase::StabilizeReadback, "stabilize_readback"),
|
||||
(ScannerPauseBacklogRetirementPhase::FinalCheck, "final_check"),
|
||||
];
|
||||
for (phase, label) in phases {
|
||||
let unavailable = match plan_scanner_pause_backlog_retirement(0, &[], phase) {
|
||||
Ok(_) => panic!("unregistered planner must fail during {label}"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
unavailable.to_string(),
|
||||
format!("Io error: scanner pause backlog native retirement planner is unavailable during {label}"),
|
||||
);
|
||||
let Error::Io(io_error) = &unavailable else {
|
||||
panic!("unregistered planner must retain its IO error boundary during {label}");
|
||||
};
|
||||
let recovered = io_error
|
||||
.get_ref()
|
||||
.and_then(|cause| cause.downcast_ref::<ScannerPauseBacklogPlannerUnavailable>())
|
||||
.expect("unregistered planner must retain its typed cause");
|
||||
assert_eq!(recovered.phase, label);
|
||||
assert!(data_movement_stage_source_as::<ScannerPauseBacklogRetirementError>(&unavailable).is_none());
|
||||
let same_failure = plan_scanner_pause_backlog_retirement(0, &[], phase)
|
||||
.err()
|
||||
.expect("a second unregistered call must fail in the same phase");
|
||||
let errors = [
|
||||
Some(
|
||||
unavailable
|
||||
.narrow_to_disk()
|
||||
.expect("planner IO error must cross the disk boundary"),
|
||||
),
|
||||
Some(
|
||||
same_failure
|
||||
.narrow_to_disk()
|
||||
.expect("same-phase planner IO error must cross the disk boundary"),
|
||||
),
|
||||
];
|
||||
let (count, _) = crate::disk::error_reduce::reduce_errs(&errors, &[]);
|
||||
assert_eq!(count, 2, "same-phase planner failures must share one quorum bucket");
|
||||
}
|
||||
register_scanner_pause_backlog_retirement_planner(failing_planner);
|
||||
for (phase, label) in phases {
|
||||
for (source_pool_index, source) in [
|
||||
ScannerPauseBacklogRetirementError::HandoffConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
ScannerPauseBacklogRetirementError::AuthorityConflict {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
ScannerPauseBacklogRetirementError::InvalidRecord {
|
||||
reason: "original reason".to_owned(),
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let discriminant = std::mem::discriminant(&source);
|
||||
let retryable = source.is_retryable_handoff_conflict();
|
||||
let original = source.to_string();
|
||||
let error = match plan_scanner_pause_backlog_retirement(source_pool_index, &[], phase) {
|
||||
Ok(_) => panic!("the registered planner fixture must fail during {label}"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!("Io error: scanner pause backlog retirement planner failed during {label}: {original}")
|
||||
);
|
||||
let recovered = data_movement_stage_source_as::<ScannerPauseBacklogRetirementError>(&error)
|
||||
.expect("the worker classifier must still reach the original native error");
|
||||
assert_eq!(std::mem::discriminant(recovered), discriminant);
|
||||
assert_eq!(recovered.to_string(), original);
|
||||
assert_eq!(recovered.is_retryable_handoff_conflict(), retryable);
|
||||
let mut source = std::error::Error::source(&error);
|
||||
let mut found_native = false;
|
||||
while let Some(cause) = source {
|
||||
found_native |= cause.downcast_ref::<ScannerPauseBacklogRetirementError>().is_some();
|
||||
source = cause.source();
|
||||
}
|
||||
assert!(found_native, "the standard source chain must retain the native error during {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+479
-101
@@ -235,9 +235,19 @@ enum ReadAllError {
|
||||
|
||||
struct ListingMetadataRead {
|
||||
bytes: Vec<u8>,
|
||||
#[cfg(test)]
|
||||
file_meta: Option<FileMeta>,
|
||||
data_dirs: HashSet<String>,
|
||||
data_dirs: Vec<Uuid>,
|
||||
has_namespace_child_candidate: bool,
|
||||
counts_toward_limit: bool,
|
||||
}
|
||||
|
||||
type ListingMetadataItem = core::result::Result<ListingMetadataRead, ReadAllError>;
|
||||
|
||||
struct ListingMetadataBatch {
|
||||
volume_dir: PathBuf,
|
||||
rx: tokio::sync::mpsc::UnboundedReceiver<ListingMetadataItem>,
|
||||
remaining: usize,
|
||||
}
|
||||
|
||||
const LISTING_META_INITIAL_READ_BYTES: usize = 4 << 10;
|
||||
@@ -272,31 +282,56 @@ fn read_listing_metadata_bytes_std(
|
||||
opened: std::io::Result<std::fs::File>,
|
||||
) -> core::result::Result<(Vec<u8>, Option<OffsetDateTime>), ReadAllError> {
|
||||
let mut file = opened.map_err(ReadAllError::Open)?;
|
||||
let metadata = file.metadata().map_err(|err| ReadAllError::Disk(to_file_error(err).into()))?;
|
||||
let mut initial_buf = [0u8; LISTING_META_INITIAL_READ_BYTES];
|
||||
let mut n = 0;
|
||||
let mut reached_eof = false;
|
||||
|
||||
if metadata.is_dir() {
|
||||
return Err(ReadAllError::Disk(DiskError::FileNotFound));
|
||||
while n < LISTING_META_INITIAL_READ_BYTES {
|
||||
match std::io::Read::read(&mut file, &mut initial_buf[n..]) {
|
||||
Ok(0) => {
|
||||
reached_eof = true;
|
||||
break;
|
||||
}
|
||||
Ok(read_bytes) => {
|
||||
n += read_bytes;
|
||||
if let Ok((tmp_buf, 1, minor @ 1..=3)) = FileMeta::check_xl2_v1(&initial_buf[..n])
|
||||
&& let Ok((sz, rest)) = FileMeta::read_bytes_header(tmp_buf)
|
||||
{
|
||||
let header_len = n - rest.len();
|
||||
let crc_len = if minor >= 2 { 5 } else { 0 };
|
||||
if let Some(want) = (sz as usize).checked_add(header_len).and_then(|v| v.checked_add(crc_len))
|
||||
&& want <= n
|
||||
{
|
||||
return Ok((initial_buf[..want].to_vec(), None));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
|
||||
#[cfg(unix)]
|
||||
Err(err) if err.raw_os_error() == Some(libc::EISDIR) => {
|
||||
return Err(ReadAllError::Disk(DiskError::FileNotFound));
|
||||
}
|
||||
Err(err) => {
|
||||
#[cfg(not(unix))]
|
||||
if file.metadata().is_ok_and(|m| m.is_dir()) {
|
||||
return Err(ReadAllError::Disk(DiskError::FileNotFound));
|
||||
}
|
||||
return Err(ReadAllError::Disk(to_file_error(err).into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let size = usize::try_from(metadata.len()).map_err(|err| ReadAllError::Disk(DiskError::other(err)))?;
|
||||
let modtime = metadata.modified().ok().map(OffsetDateTime::from);
|
||||
if size == 0 {
|
||||
return Ok((Vec::new(), modtime));
|
||||
if n == 0 {
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
|
||||
let initial = size.min(LISTING_META_INITIAL_READ_BYTES);
|
||||
let mut bytes = Vec::new();
|
||||
bytes
|
||||
.try_reserve_exact(initial)
|
||||
.map_err(|err| ReadAllError::Disk(Error::other(err)))?;
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, initial as u64), &mut bytes)
|
||||
.map_err(|err| ReadAllError::Disk(to_file_error(err).into()))?;
|
||||
|
||||
if let Ok((tmp_buf, major, minor)) = FileMeta::check_xl2_v1(&bytes) {
|
||||
if !reached_eof && let Ok((tmp_buf, major, minor)) = FileMeta::check_xl2_v1(&initial_buf) {
|
||||
let metadata = file.metadata().map_err(|err| ReadAllError::Disk(to_file_error(err).into()))?;
|
||||
let size = usize::try_from(metadata.len()).map_err(|err| ReadAllError::Disk(DiskError::other(err)))?;
|
||||
let want = match (major, minor) {
|
||||
(1, 0) => Some(size),
|
||||
(1, 1..=3) => FileMeta::read_bytes_header(tmp_buf).ok().and_then(|(sz, rest)| {
|
||||
let header_len = bytes.len() - rest.len();
|
||||
let header_len = initial_buf.len() - rest.len();
|
||||
let crc_len = if minor >= 2 { 5 } else { 0 };
|
||||
(sz as usize).checked_add(header_len)?.checked_add(crc_len)
|
||||
}),
|
||||
@@ -304,21 +339,83 @@ fn read_listing_metadata_bytes_std(
|
||||
};
|
||||
if let Some(want) = want
|
||||
&& want <= size
|
||||
&& want > initial_buf.len()
|
||||
{
|
||||
if want > bytes.len() && bytes.len() == initial {
|
||||
let extra = want - bytes.len();
|
||||
bytes
|
||||
.try_reserve_exact(extra)
|
||||
.map_err(|err| ReadAllError::Disk(Error::other(err)))?;
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, extra as u64), &mut bytes)
|
||||
.map_err(|err| ReadAllError::Disk(to_file_error(err).into()))?;
|
||||
} else if want < bytes.len() {
|
||||
bytes.truncate(want);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
bytes
|
||||
.try_reserve_exact(want.saturating_add(1))
|
||||
.map_err(|err| ReadAllError::Disk(Error::other(err)))?;
|
||||
bytes.extend_from_slice(&initial_buf);
|
||||
let extra = want - initial_buf.len();
|
||||
std::io::Read::read_to_end(&mut std::io::Read::take(&mut file, extra as u64), &mut bytes)
|
||||
.map_err(|err| ReadAllError::Disk(to_file_error(err).into()))?;
|
||||
return Ok((bytes, None));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((bytes, modtime))
|
||||
Ok((initial_buf[..n].to_vec(), None))
|
||||
}
|
||||
|
||||
const LISTING_META_BATCH_MAX_BYTES: usize = 1 << 20;
|
||||
|
||||
// Deliver progress before starting another blocking read. A timer checked after a
|
||||
// read cannot publish completed entries while the next filesystem call is stalled.
|
||||
fn stream_listing_metadata_batch(
|
||||
object_names: &[String],
|
||||
tx: tokio::sync::mpsc::UnboundedSender<ListingMetadataItem>,
|
||||
mut read: impl FnMut(&str) -> ListingMetadataItem,
|
||||
) {
|
||||
let mut buffered_bytes = 0usize;
|
||||
for object_name in object_names {
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
let res = read(object_name);
|
||||
if let Ok(read) = &res {
|
||||
buffered_bytes = buffered_bytes.saturating_add(read.bytes.len());
|
||||
}
|
||||
let stop = res.as_ref().map_or(true, |read| read.has_namespace_child_candidate)
|
||||
|| buffered_bytes >= LISTING_META_BATCH_MAX_BYTES;
|
||||
if tx.send(res).is_err() || stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_listing_metadata_bytes(bytes: &[u8]) -> (Option<FileMeta>, Vec<Uuid>, bool) {
|
||||
let file_meta = FileMeta::load(bytes).ok();
|
||||
let mut data_dirs = Vec::new();
|
||||
if let Some(meta) = file_meta.as_ref()
|
||||
&& let Ok(dirs) = meta.get_data_dirs()
|
||||
{
|
||||
data_dirs.extend(dirs.into_iter().flatten());
|
||||
data_dirs.sort_unstable();
|
||||
data_dirs.dedup();
|
||||
}
|
||||
let counts_toward_limit = file_meta.as_ref().is_none_or(file_meta_counts_toward_limit);
|
||||
(file_meta, data_dirs, counts_toward_limit)
|
||||
}
|
||||
|
||||
fn read_listing_metadata_entry_std(
|
||||
root: &os::PublicationRoot,
|
||||
object_dir: &Path,
|
||||
) -> core::result::Result<ListingMetadataRead, ReadAllError> {
|
||||
let metadata_path = object_dir.join(STORAGE_FORMAT_FILE);
|
||||
let (bytes, _) = read_listing_metadata_bytes_std(root.open_readonly(&metadata_path))?;
|
||||
let (file_meta, data_dirs, counts_toward_limit) = parse_listing_metadata_bytes(&bytes);
|
||||
#[cfg(not(test))]
|
||||
let _ = file_meta;
|
||||
let has_namespace_child_candidate =
|
||||
os::has_non_data_dir_entry(object_dir, &data_dirs).map_err(|err| ReadAllError::Disk(to_file_error(err).into()))?;
|
||||
|
||||
Ok(ListingMetadataRead {
|
||||
bytes,
|
||||
#[cfg(test)]
|
||||
file_meta,
|
||||
data_dirs,
|
||||
has_namespace_child_candidate,
|
||||
counts_toward_limit,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn inline_metadata_rollback_dir(version_id: Uuid, meta: &FileMeta) -> Uuid {
|
||||
@@ -7974,49 +8071,54 @@ impl LocalDisk {
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn read_listing_metadata(&self, volume: &str, object_name: &str) -> Result<ListingMetadataRead> {
|
||||
let io_permit = acquire_context_disk_io_permit().await.map_err(DiskError::Io)?;
|
||||
let object_dir = self.io_get_object_path(volume, object_name)?;
|
||||
let metadata_path = object_dir.join(STORAGE_FORMAT_FILE);
|
||||
let mut batch = self
|
||||
.spawn_listing_metadata_batch(volume, vec![object_name.to_owned()], None)
|
||||
.await?;
|
||||
let raw = batch
|
||||
.rx
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_else(|| DiskError::other("listing metadata batch closed unexpectedly"))?;
|
||||
self.resolve_read_all_result(volume, &batch.volume_dir, raw).await
|
||||
}
|
||||
|
||||
async fn spawn_listing_metadata_batch(
|
||||
&self,
|
||||
volume: &str,
|
||||
object_names: Vec<String>,
|
||||
stall: Option<Duration>,
|
||||
) -> Result<ListingMetadataBatch> {
|
||||
let io_permit = with_walk_stall_deadline(stall, acquire_context_disk_io_permit())
|
||||
.await?
|
||||
.map_err(DiskError::Io)?;
|
||||
let volume_dir = self.io_get_bucket_path(volume)?;
|
||||
let io_root = self.io_root().to_path_buf();
|
||||
#[cfg(target_os = "linux")]
|
||||
let mount_lease = self.mount_lease.clone();
|
||||
let volume_str = volume.to_owned();
|
||||
let root = self.publication_root.clone();
|
||||
let result = spawn_blocking_with_disk_io_permit(io_permit, move || {
|
||||
let (bytes, _) = read_listing_metadata_bytes_std(root.open_readonly(&metadata_path))?;
|
||||
let file_meta = FileMeta::load(&bytes).ok();
|
||||
let data_dirs: HashSet<String> = file_meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get_data_dirs().ok())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|data_dir| data_dir.to_string())
|
||||
.collect();
|
||||
let probe = match os::read_dir_probe(&object_dir, data_dirs.len().saturating_add(2)) {
|
||||
Ok(probe) => probe,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => os::ReadDirProbe {
|
||||
entries: Vec::new(),
|
||||
complete: true,
|
||||
},
|
||||
Err(err) => return Err(ReadAllError::Disk(to_file_error(err).into())),
|
||||
};
|
||||
let found_child = probe.entries.into_iter().any(|entry| {
|
||||
entry
|
||||
.strip_suffix(SLASH_SEPARATOR)
|
||||
.is_some_and(|child| !child.is_empty() && !data_dirs.contains(child))
|
||||
let remaining = object_names.len();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
drop(spawn_blocking_with_disk_io_permit(io_permit, move || {
|
||||
stream_listing_metadata_batch(&object_names, tx, |object_name| {
|
||||
let (bucket_path, object_dir) = build_local_disk_object_path(&io_root, &volume_str, object_name);
|
||||
#[cfg(target_os = "linux")]
|
||||
let path_check = check_local_disk_valid_object_path_at(&io_root, &mount_lease, &bucket_path, &object_dir);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let path_check = check_local_disk_valid_object_path(&io_root, &bucket_path, &object_dir);
|
||||
match path_check {
|
||||
Ok(()) => read_listing_metadata_entry_std(&root, &object_dir),
|
||||
Err(err) => Err(ReadAllError::Disk(err)),
|
||||
}
|
||||
});
|
||||
let has_namespace_child_candidate = found_child || !probe.complete;
|
||||
|
||||
Ok(ListingMetadataRead {
|
||||
bytes,
|
||||
file_meta,
|
||||
data_dirs,
|
||||
has_namespace_child_candidate,
|
||||
})
|
||||
}));
|
||||
Ok(ListingMetadataBatch {
|
||||
volume_dir,
|
||||
rx,
|
||||
remaining,
|
||||
})
|
||||
.await
|
||||
.map_err(DiskError::from)?;
|
||||
|
||||
self.resolve_read_all_result(volume, &volume_dir, result).await
|
||||
}
|
||||
|
||||
async fn resolve_read_all_result<T>(
|
||||
@@ -9231,8 +9333,10 @@ impl LocalDisk {
|
||||
}
|
||||
};
|
||||
prefix = "".to_owned();
|
||||
let mut prefetched_batch: Option<ListingMetadataBatch> = None;
|
||||
let mut adaptive_batch_cap = WALK_DIR_BATCH_SIZE;
|
||||
|
||||
for entry in entries.iter() {
|
||||
for (entry_idx, entry) in entries.iter().enumerate() {
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -9317,50 +9421,93 @@ impl LocalDisk {
|
||||
meta.name.push_str(GLOBAL_DIR_SUFFIX_WITH_SLASH);
|
||||
}
|
||||
|
||||
let fname = format!("{}/{}", meta.name, STORAGE_FORMAT_FILE);
|
||||
let metadata_read = if !is_dir_obj {
|
||||
with_walk_stall_timeout(stall, self.read_listing_metadata(&opts.bucket, &meta.name))
|
||||
.await
|
||||
.map(|read| (read.bytes, read.file_meta, Some(read.data_dirs), read.has_namespace_child_candidate))
|
||||
async {
|
||||
let mut spawned = false;
|
||||
let (volume_dir, raw_res) = loop {
|
||||
if let Some(batch) = prefetched_batch.as_mut() {
|
||||
if let Some(raw_res) = with_walk_stall_deadline(stall, batch.rx.recv()).await? {
|
||||
batch.remaining -= 1;
|
||||
let stop = batch.remaining == 0
|
||||
|| raw_res.as_ref().map_or(true, |r| r.has_namespace_child_candidate);
|
||||
let volume_dir = batch.volume_dir.clone();
|
||||
if stop {
|
||||
prefetched_batch = None;
|
||||
}
|
||||
break (volume_dir, raw_res);
|
||||
}
|
||||
prefetched_batch = None;
|
||||
if spawned {
|
||||
return Err(DiskError::other("listing metadata batch closed unexpectedly"));
|
||||
}
|
||||
}
|
||||
spawned = true;
|
||||
let batch_limit = if opts.limit > 0 {
|
||||
usize::try_from(opts.limit - *objs_returned)
|
||||
.unwrap_or(1)
|
||||
.min(adaptive_batch_cap)
|
||||
} else {
|
||||
adaptive_batch_cap
|
||||
};
|
||||
let batch_names = if batch_limit > 1
|
||||
&& dir_objes.is_empty()
|
||||
&& dir_stack.iter().all(|(_, _, _, scan_required)| !*scan_required)
|
||||
{
|
||||
entries[entry_idx..]
|
||||
.iter()
|
||||
.take_while(|c| !c.is_empty())
|
||||
.take(batch_limit)
|
||||
.map(|c| path_join_buf(&[current.as_str(), c.as_str()]))
|
||||
.collect()
|
||||
} else {
|
||||
vec![meta.name.clone()]
|
||||
};
|
||||
prefetched_batch = Some(self.spawn_listing_metadata_batch(&opts.bucket, batch_names, stall).await?);
|
||||
};
|
||||
with_walk_stall_timeout(stall, self.resolve_read_all_result(&opts.bucket, &volume_dir, raw_res)).await
|
||||
}
|
||||
.await
|
||||
} else {
|
||||
let fname = format!("{}/{}", meta.name, STORAGE_FORMAT_FILE);
|
||||
with_walk_stall_timeout(stall, self.read_metadata(&opts.bucket, fname.as_str()))
|
||||
.await
|
||||
.map(|metadata| (Vec::from(metadata), None, None, true))
|
||||
.map(|metadata| {
|
||||
let bytes = Vec::from(metadata);
|
||||
let (file_meta, data_dirs, counts_toward_limit) = parse_listing_metadata_bytes(&bytes);
|
||||
#[cfg(not(test))]
|
||||
let _ = file_meta;
|
||||
ListingMetadataRead {
|
||||
bytes,
|
||||
#[cfg(test)]
|
||||
file_meta,
|
||||
data_dirs,
|
||||
has_namespace_child_candidate: true,
|
||||
counts_toward_limit,
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
match metadata_read {
|
||||
Ok((res, prefetched_file_meta, prefetched_data_dirs, has_namespace_child_candidate)) => {
|
||||
Ok(read) => {
|
||||
adaptive_batch_cap = WALK_DIR_BATCH_SIZE;
|
||||
if is_dir_obj {
|
||||
meta.name = meta.name.trim_end_matches(GLOBAL_DIR_SUFFIX_WITH_SLASH).to_owned();
|
||||
meta.name.push_str(SLASH_SEPARATOR);
|
||||
}
|
||||
|
||||
let file_meta = match prefetched_file_meta {
|
||||
Some(file_meta) => Some(file_meta),
|
||||
None if opts.limit > 0 || opts.recursive || !is_dir_obj => FileMeta::load(&res).ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
meta.metadata = res;
|
||||
meta.metadata = read.bytes;
|
||||
|
||||
write_metacache_obj(out, &meta).await?;
|
||||
|
||||
if opts.limit <= 0 || file_meta.as_ref().is_none_or(file_meta_counts_toward_limit) {
|
||||
if opts.limit <= 0 || read.counts_toward_limit {
|
||||
*objs_returned += 1;
|
||||
}
|
||||
|
||||
let dir_to_skip = if let Some(data_dirs) = prefetched_data_dirs {
|
||||
data_dirs
|
||||
let has_namespace_child_candidate = read.has_namespace_child_candidate;
|
||||
let dir_to_skip: HashSet<String> = if has_namespace_child_candidate || !dir_objes.is_empty() {
|
||||
read.data_dirs.into_iter().map(|dir| dir.to_string()).collect()
|
||||
} else {
|
||||
let mut data_dirs_to_skip = HashSet::new();
|
||||
if let Some(file_meta) = file_meta.as_ref()
|
||||
&& let Ok(data_dirs) = file_meta.get_data_dirs()
|
||||
{
|
||||
for data_dir in data_dirs.iter().flatten() {
|
||||
data_dirs_to_skip.insert(data_dir.to_string());
|
||||
}
|
||||
}
|
||||
data_dirs_to_skip
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
if opts.recursive {
|
||||
@@ -9394,6 +9541,7 @@ impl LocalDisk {
|
||||
}
|
||||
Err(err) => {
|
||||
if err == Error::FileNotFound || err == Error::IsNotRegular {
|
||||
adaptive_batch_cap = 1;
|
||||
// NOT an object, append to stack (with slash)
|
||||
// If dirObject, but no metadata (which is unexpected) we skip it.
|
||||
if !is_dir_obj && {
|
||||
@@ -9422,6 +9570,7 @@ impl LocalDisk {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fname = format!("{}/{}", meta.name, STORAGE_FORMAT_FILE);
|
||||
error!(
|
||||
event = EVENT_DISK_LOCAL_SCAN_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
@@ -9795,20 +9944,41 @@ fn rename_data_versions_signature(meta: &FileMeta) -> Option<Vec<u8>> {
|
||||
/// size field decodes independently of the inline data, so the vote carries
|
||||
/// the same value healthy disks report, and skipping the lookup's inline
|
||||
/// bytes clone keeps the observation allocation-light.
|
||||
fn observe_old_current_size(dst_meta_existed: bool, xlmeta: &FileMeta) -> Option<OldCurrentSize> {
|
||||
fn observe_old_current(
|
||||
dst_meta_existed: bool,
|
||||
dst_meta_unparsable: bool,
|
||||
xlmeta: &FileMeta,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
version_id: Uuid,
|
||||
) -> (Option<OldCurrentSize>, bool, Option<FileInfo>) {
|
||||
if dst_meta_existed && dst_meta_unparsable {
|
||||
return (None, false, None);
|
||||
}
|
||||
if !dst_meta_existed {
|
||||
return Some(OldCurrentSize::Absent);
|
||||
return (Some(OldCurrentSize::Absent), true, None);
|
||||
}
|
||||
if xlmeta.versions.is_empty() {
|
||||
return Some(OldCurrentSize::Present(0));
|
||||
return (Some(OldCurrentSize::Present(0)), false, None);
|
||||
}
|
||||
match xlmeta.into_fileinfo("", "", "", false, false, true) {
|
||||
Ok(fi) => Some(OldCurrentSize::Present(fi.size)),
|
||||
Err(rustfs_filemeta::Error::FileNotFound) => Some(OldCurrentSize::Absent),
|
||||
Err(_) => None,
|
||||
match xlmeta.into_fileinfo(volume, path, "", false, false, true) {
|
||||
Ok(old_current) => {
|
||||
let size = Some(OldCurrentSize::Present(old_current.size));
|
||||
let source = (old_current.version_id.unwrap_or_default() == version_id
|
||||
&& old_current.transition_status == rustfs_filemeta::TRANSITION_COMPLETE)
|
||||
.then_some(old_current);
|
||||
(size, true, source)
|
||||
}
|
||||
Err(rustfs_filemeta::Error::FileNotFound) => (Some(OldCurrentSize::Absent), false, None),
|
||||
Err(_) => (None, false, None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn observe_old_current_size(dst_meta_existed: bool, xlmeta: &FileMeta) -> Option<OldCurrentSize> {
|
||||
observe_old_current(dst_meta_existed, false, xlmeta, "", "", Uuid::nil()).0
|
||||
}
|
||||
|
||||
fn is_root_path(path: impl AsRef<Path>) -> bool {
|
||||
path.as_ref().components().count() == 1 && path.as_ref().has_root()
|
||||
}
|
||||
@@ -18624,6 +18794,69 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_preserves_source_check_decisions_for_missing_empty_and_corrupt_metadata() {
|
||||
for (exists, unparsable, expected_size, expected_checked) in [
|
||||
(false, false, Some(OldCurrentSize::Absent), true),
|
||||
(true, false, Some(OldCurrentSize::Present(0)), false),
|
||||
(true, true, None, false),
|
||||
] {
|
||||
let (size, checked, source) =
|
||||
observe_old_current(exists, unparsable, &FileMeta::new(), "bucket", "object", Uuid::nil());
|
||||
assert_eq!(size, expected_size);
|
||||
assert_eq!(checked, expected_checked);
|
||||
assert!(source.is_none());
|
||||
}
|
||||
|
||||
let mut metadata = FileMeta::new();
|
||||
metadata
|
||||
.add_version(live_file_info("object", Uuid::new_v4(), 42, OffsetDateTime::now_utc()))
|
||||
.expect("seed a live version");
|
||||
metadata.versions[0].meta = vec![0xc1];
|
||||
let (size, checked, source) = observe_old_current(true, false, &metadata, "bucket", "object", Uuid::nil());
|
||||
assert_eq!(size, None);
|
||||
assert!(!checked);
|
||||
assert!(source.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_returns_only_the_matching_transitioned_old_current_source() {
|
||||
let version_id = Uuid::new_v4();
|
||||
let mut info = live_file_info("object", version_id, 42, OffsetDateTime::now_utc());
|
||||
info.transition_status = rustfs_filemeta::TRANSITION_COMPLETE.to_string();
|
||||
info.transition_tier = "WARM".to_string();
|
||||
info.transitioned_objname = "remote-object".to_string();
|
||||
let mut metadata = FileMeta::new();
|
||||
metadata.add_version(info).expect("seed transitioned current version");
|
||||
|
||||
for (requested, expected_source) in [(version_id, true), (Uuid::new_v4(), false)] {
|
||||
let (size, checked, source) = observe_old_current(true, false, &metadata, "bucket", "object", requested);
|
||||
assert_eq!(size, Some(OldCurrentSize::Present(42)));
|
||||
assert!(checked);
|
||||
assert_eq!(source.is_some(), expected_source);
|
||||
if let Some(source) = source {
|
||||
assert_eq!(source.version_id, Some(version_id));
|
||||
assert_eq!(source.name, "object");
|
||||
assert_eq!(source.volume, "bucket");
|
||||
assert_eq!(source.transition_status, rustfs_filemeta::TRANSITION_COMPLETE);
|
||||
assert_eq!(source.transition_tier, "WARM");
|
||||
assert_eq!(source.transitioned_objname, "remote-object");
|
||||
}
|
||||
}
|
||||
|
||||
metadata
|
||||
.add_version(delete_marker_file_info(
|
||||
"object",
|
||||
Uuid::new_v4(),
|
||||
OffsetDateTime::now_utc() + time::Duration::seconds(1),
|
||||
))
|
||||
.expect("seed newer delete marker");
|
||||
let (size, checked, source) = observe_old_current(true, false, &metadata, "bucket", "object", version_id);
|
||||
assert_eq!(size, Some(OldCurrentSize::Present(0)));
|
||||
assert!(checked);
|
||||
assert!(source.is_none(), "a historical transitioned version is not the old current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_reports_absent_for_missing_key() {
|
||||
assert_eq!(observe_old_current_size(false, &FileMeta::default()), Some(OldCurrentSize::Absent));
|
||||
@@ -22734,7 +22967,7 @@ mod test {
|
||||
.await
|
||||
.expect("read leaf metadata");
|
||||
assert!(!leaf.has_namespace_child_candidate);
|
||||
assert!(leaf.data_dirs.contains(&data_dir.to_string()));
|
||||
assert!(leaf.data_dirs.contains(&data_dir));
|
||||
|
||||
fs::create_dir(object_dir.join("child"))
|
||||
.await
|
||||
@@ -22746,6 +22979,94 @@ mod test {
|
||||
assert!(parent.has_namespace_child_candidate);
|
||||
}
|
||||
|
||||
fn listing_batch_fixture(bytes: Vec<u8>, has_namespace_child_candidate: bool) -> ListingMetadataRead {
|
||||
ListingMetadataRead {
|
||||
bytes,
|
||||
file_meta: None,
|
||||
data_dirs: Vec::new(),
|
||||
has_namespace_child_candidate,
|
||||
counts_toward_limit: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_metadata_batch_publishes_before_the_next_read_completes() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (resume_tx, resume_rx) = std::sync::mpsc::channel();
|
||||
let worker = tokio::task::spawn_blocking(move || {
|
||||
let mut started_tx = Some(started_tx);
|
||||
stream_listing_metadata_batch(&["first".to_string(), "second".to_string()], tx, |name| {
|
||||
if name == "second" {
|
||||
started_tx
|
||||
.take()
|
||||
.expect("second read starts once")
|
||||
.send(())
|
||||
.expect("notify reader");
|
||||
resume_rx.recv().expect("resume second read");
|
||||
}
|
||||
Ok(listing_batch_fixture(name.as_bytes().to_vec(), false))
|
||||
});
|
||||
});
|
||||
started_rx.await.expect("worker must reach the blocked second read");
|
||||
let first = rx.try_recv();
|
||||
// Release the blocking task before asserting, including on regression.
|
||||
resume_tx.send(()).expect("release blocked worker");
|
||||
worker.await.expect("worker must finish");
|
||||
assert_eq!(
|
||||
first
|
||||
.expect("first result must already be published")
|
||||
.ok()
|
||||
.expect("first read succeeds")
|
||||
.bytes,
|
||||
b"first"
|
||||
);
|
||||
assert_eq!(
|
||||
rx.recv()
|
||||
.await
|
||||
.expect("second result")
|
||||
.ok()
|
||||
.expect("second read succeeds")
|
||||
.bytes,
|
||||
b"second"
|
||||
);
|
||||
assert!(rx.recv().await.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_metadata_batch_stops_on_boundaries_and_cancellation() {
|
||||
for boundary in ["bytes", "child", "error", "cancel"] {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
if boundary == "cancel" {
|
||||
rx.close();
|
||||
}
|
||||
let mut reads = 0;
|
||||
stream_listing_metadata_batch(&["first".to_string(), "second".to_string()], tx, |_| {
|
||||
reads += 1;
|
||||
if boundary == "error" {
|
||||
Err(ReadAllError::Disk(DiskError::FileNotFound))
|
||||
} else {
|
||||
Ok(listing_batch_fixture(
|
||||
vec![
|
||||
0;
|
||||
if boundary == "bytes" {
|
||||
LISTING_META_BATCH_MAX_BYTES
|
||||
} else {
|
||||
1
|
||||
}
|
||||
],
|
||||
boundary == "child",
|
||||
))
|
||||
}
|
||||
});
|
||||
assert_eq!(reads, usize::from(boundary != "cancel"), "boundary: {boundary}");
|
||||
if boundary != "cancel" {
|
||||
assert!(rx.try_recv().is_ok(), "the boundary result must be delivered: {boundary}");
|
||||
}
|
||||
assert!(rx.try_recv().is_err(), "no result may follow the boundary: {boundary}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_metadata_skips_inline_payload_bytes_and_preserves_corrupt_classification() {
|
||||
use tempfile::tempdir;
|
||||
@@ -22953,6 +23274,12 @@ mod test {
|
||||
fi.data_dir = Some(data_dir);
|
||||
fi.size = 16;
|
||||
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("ts"));
|
||||
if name == "obj-20" {
|
||||
// A single envelope can exhaust the byte budget before the name
|
||||
// batch ends; subsequent keys must resume in a fresh worker.
|
||||
fi.metadata
|
||||
.insert("x-amz-meta-padding".to_string(), "p".repeat(LISTING_META_BATCH_MAX_BYTES));
|
||||
}
|
||||
fi.data = Some(Bytes::from(vec![b'a'; 16 * 1024]));
|
||||
fi.set_inline_data();
|
||||
fm.add_version(fi).expect("add version");
|
||||
@@ -23012,6 +23339,57 @@ mod test {
|
||||
|
||||
assert_eq!(entries[2].name, "parent/");
|
||||
assert!(entries[2].is_dir());
|
||||
|
||||
// Cross multiple batches, including an early stop for a nested child, and
|
||||
// pin both sides of the 32-object batch boundary and an unlimited walk.
|
||||
let batch_bucket = "walk-batch-bucket";
|
||||
let batch_bucket_dir = dir.path().join(batch_bucket);
|
||||
for idx in 0..80 {
|
||||
let name = format!("obj-{idx:02}");
|
||||
let obj_dir = batch_bucket_dir.join(&name);
|
||||
fs::create_dir_all(obj_dir.join(data_dir.to_string()))
|
||||
.await
|
||||
.expect("create batch obj data_dir");
|
||||
fs::write(obj_dir.join(STORAGE_FORMAT_FILE), make_obj_meta(&name))
|
||||
.await
|
||||
.expect("write batch obj xl.meta");
|
||||
if idx == 10 {
|
||||
let nested_dir = obj_dir.join("nested");
|
||||
fs::create_dir_all(&nested_dir).await.expect("create nested child dir");
|
||||
fs::write(nested_dir.join(STORAGE_FORMAT_FILE), make_obj_meta("obj-10/nested"))
|
||||
.await
|
||||
.expect("write nested child xl.meta");
|
||||
}
|
||||
}
|
||||
|
||||
let mut expected: Vec<String> = (0..80).map(|i| format!("obj-{i:02}")).collect();
|
||||
expected.insert(11, "obj-10/nested".to_string());
|
||||
for limit in [15, 32, 33, 0] {
|
||||
let (reader, mut writer) = tokio::io::duplex(64 * 1024);
|
||||
let batch_opts = WalkDirOptions {
|
||||
bucket: batch_bucket.to_string(),
|
||||
base_dir: String::new(),
|
||||
recursive: true,
|
||||
limit,
|
||||
..Default::default()
|
||||
};
|
||||
let disk_ref = &disk;
|
||||
let produce = async move { disk_ref.walk_dir(batch_opts, &mut writer).await };
|
||||
let consume = async { MetacacheReader::new(reader).read_all().await.expect("read batched stream") };
|
||||
let (result, batch_entries) = tokio::join!(produce, consume);
|
||||
result.expect("batched recursive walk_dir must succeed");
|
||||
let visible_names: Vec<_> = batch_entries
|
||||
.iter()
|
||||
.filter(|e| !e.metadata.is_empty())
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
let count = if limit == 0 {
|
||||
expected.len()
|
||||
} else {
|
||||
usize::try_from(limit).expect("positive limit")
|
||||
};
|
||||
assert_eq!(visible_names, expected[..count], "page limit {limit}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -21,7 +21,7 @@ use super::run_destination_commit_directory_preparation;
|
||||
use super::should_fail_local_inline_rollback_hardlink;
|
||||
use super::{
|
||||
EVENT_DISK_LOCAL_ACCESS_FAILED, EVENT_DISK_LOCAL_HEAL_PURGE_FAILED, EVENT_DISK_LOCAL_RENAME_REJECTED, LOG_COMPONENT_ECSTORE,
|
||||
LOG_SUBSYSTEM_DISK_LOCAL, LocalDisk, SyncMode, effective_durability, inline_metadata_rollback_dir, observe_old_current_size,
|
||||
LOG_SUBSYSTEM_DISK_LOCAL, LocalDisk, SyncMode, effective_durability, inline_metadata_rollback_dir, observe_old_current,
|
||||
remove_dir_all_if_exists, remove_dst_base_before_commit, remove_file_if_exists, rename_data_versions_signature,
|
||||
run_inline_preparation_before_backup, should_fail_after_metadata_commit, should_fail_before_old_metadata_backup,
|
||||
should_fail_commit_rename, should_remove_staged_meta_before_commit, skip_access_checks, tmp_staging_mutation_path,
|
||||
@@ -47,33 +47,6 @@ use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn transitioned_old_current_source(
|
||||
dst_meta_existed: bool,
|
||||
dst_meta_unparsable: bool,
|
||||
xlmeta: &FileMeta,
|
||||
volume: &str,
|
||||
path: &str,
|
||||
version_id: Uuid,
|
||||
) -> (bool, Option<FileInfo>) {
|
||||
if dst_meta_unparsable {
|
||||
return (false, None);
|
||||
}
|
||||
if !dst_meta_existed {
|
||||
return (true, None);
|
||||
}
|
||||
let Ok(old_current) = xlmeta.into_fileinfo(volume, path, "", false, false, true) else {
|
||||
return (false, None);
|
||||
};
|
||||
if old_current.version_id.unwrap_or_default() != version_id {
|
||||
return (true, None);
|
||||
}
|
||||
if old_current.transition_status == rustfs_filemeta::TRANSITION_COMPLETE {
|
||||
(true, Some(old_current))
|
||||
} else {
|
||||
(true, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold later repair publications after admitting one baseline object. The
|
||||
/// fixture arms this on one replacement disk before rejoining the cluster.
|
||||
#[cfg(feature = "e2e-test-hooks")]
|
||||
@@ -526,12 +499,6 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
let old_current_size = if dst_meta_unparsable {
|
||||
None
|
||||
} else {
|
||||
observe_old_current_size(has_dst_buf.is_some(), &xlmeta)
|
||||
};
|
||||
|
||||
let mut skip_parent = dst_volume_dir.clone();
|
||||
if has_dst_buf.as_ref().is_some()
|
||||
&& let Some(parent) = dst_file_path.parent()
|
||||
@@ -540,18 +507,11 @@ impl LocalDisk {
|
||||
}
|
||||
|
||||
let version_id = fi.version_id.unwrap_or_default();
|
||||
let (old_current_source_checked, old_current_source) = transitioned_old_current_source(
|
||||
has_dst_buf.is_some(),
|
||||
dst_meta_unparsable,
|
||||
&xlmeta,
|
||||
dst_volume,
|
||||
dst_path,
|
||||
version_id,
|
||||
);
|
||||
let (old_current_size, old_current_source_checked, old_current_source) =
|
||||
observe_old_current(has_dst_buf.is_some(), dst_meta_unparsable, &xlmeta, dst_volume, dst_path, version_id);
|
||||
let has_old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
|
||||
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
|
||||
let rollback_data_dir = has_old_data_dir.or_else(|| {
|
||||
if old_version_exists && has_dst_buf.is_some() {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
@@ -993,14 +953,8 @@ impl LocalDisk {
|
||||
}
|
||||
}
|
||||
|
||||
let old_current_size = if dst_meta_unparsable {
|
||||
None
|
||||
} else {
|
||||
observe_old_current_size(has_dst_buf.is_some(), &xlmeta)
|
||||
};
|
||||
|
||||
let version_id = fi.version_id.unwrap_or_default();
|
||||
let (old_current_source_checked, old_current_source) = transitioned_old_current_source(
|
||||
let (old_current_size, old_current_source_checked, old_current_source) = observe_old_current(
|
||||
has_dst_buf.is_some(),
|
||||
dst_meta_unparsable,
|
||||
&xlmeta,
|
||||
@@ -1015,9 +969,8 @@ impl LocalDisk {
|
||||
let rollback_data_dir = if fi_healing {
|
||||
None
|
||||
} else {
|
||||
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
|
||||
old_data_dir.or_else(|| {
|
||||
if old_version_exists && has_dst_buf.is_some() {
|
||||
if has_dst_buf.is_some() && xlmeta.find_version(Some(version_id)).is_ok() {
|
||||
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -2289,11 +2289,13 @@ pub async fn is_empty_dir(path: impl AsRef<Path>) -> bool {
|
||||
|
||||
const READ_DIR_PROBE_RAW_LIMIT: usize = 256;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) struct ReadDirProbe {
|
||||
pub entries: Vec<String>,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn read_dir_probe(path: impl AsRef<Path>, entry_limit: usize) -> io::Result<ReadDirProbe> {
|
||||
let mut dir = std::fs::read_dir(path)?;
|
||||
let mut entries = Vec::with_capacity(entry_limit.min(READ_DIR_PROBE_RAW_LIMIT));
|
||||
@@ -2330,6 +2332,59 @@ pub(crate) fn read_dir_probe(path: impl AsRef<Path>, entry_limit: usize) -> io::
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn has_non_data_dir_entry(path: impl AsRef<Path>, data_dirs: &[uuid::Uuid]) -> io::Result<bool> {
|
||||
match has_non_data_dir_entry_inner(path.as_ref(), data_dirs) {
|
||||
Ok(res) => Ok(res),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_non_data_dir_entry_inner(path: &Path, data_dirs: &[uuid::Uuid]) -> io::Result<bool> {
|
||||
let mut dir = std::fs::read_dir(path)?;
|
||||
let entry_limit = data_dirs.len().saturating_add(2);
|
||||
let mut valid_entries = 0usize;
|
||||
|
||||
for _ in 0..READ_DIR_PROBE_RAW_LIMIT {
|
||||
let Some(entry) = dir.next() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let entry = entry?;
|
||||
let file_name = entry.file_name();
|
||||
if file_name.is_empty() || file_name == "." || file_name == ".." {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_type = entry.file_type()?;
|
||||
if file_type.is_dir() {
|
||||
let is_data_dir = file_name.len() == uuid::fmt::Hyphenated::LENGTH
|
||||
&& file_name
|
||||
.to_str()
|
||||
.and_then(|s| {
|
||||
let parsed = uuid::Uuid::try_parse(s).ok()?;
|
||||
let mut buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
|
||||
(parsed.hyphenated().encode_lower(&mut buf) == s && data_dirs.binary_search(&parsed).is_ok())
|
||||
.then_some(())
|
||||
})
|
||||
.is_some();
|
||||
if !is_data_dir {
|
||||
return Ok(true);
|
||||
}
|
||||
valid_entries += 1;
|
||||
} else if file_type.is_file() {
|
||||
valid_entries += 1;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if valid_entries == entry_limit {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// read_dir count read limit. when count == 0 unlimit.
|
||||
/// Return file names in the directory.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
@@ -5706,6 +5761,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_dir_probe_matches_uuid_namespace_probe() {
|
||||
let data_dir = uuid::Uuid::from_u128(0xabcdef);
|
||||
let canonical = data_dir.to_string();
|
||||
let uppercase = canonical.to_uppercase();
|
||||
let simple = data_dir.simple().to_string();
|
||||
for (directories, file_count) in [
|
||||
(vec![], 0),
|
||||
(vec![], 1),
|
||||
(vec![canonical.as_str()], 1),
|
||||
(vec![canonical.as_str(), "child"], 1),
|
||||
(vec![uppercase.as_str()], 1),
|
||||
(vec![simple.as_str()], 1),
|
||||
(vec![canonical.as_str()], 2),
|
||||
(vec![canonical.as_str()], 3),
|
||||
] {
|
||||
let root = tempdir().expect("create probe directory");
|
||||
for name in &directories {
|
||||
std::fs::create_dir(root.path().join(name)).expect("create test directory");
|
||||
}
|
||||
for i in 0..file_count {
|
||||
std::fs::write(root.path().join(format!("file-{i}")), []).expect("create test file");
|
||||
}
|
||||
let probe = read_dir_probe(root.path(), 3).expect("reference probe");
|
||||
let expected = !probe.complete
|
||||
|| probe
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.strip_suffix('/').is_some_and(|name| name != canonical));
|
||||
assert_eq!(
|
||||
has_non_data_dir_entry(root.path(), &[data_dir]).expect("UUID probe"),
|
||||
expected,
|
||||
"directories={directories:?}, files={file_count}"
|
||||
);
|
||||
}
|
||||
let missing = tempdir().expect("missing probe parent");
|
||||
assert!(!has_non_data_dir_entry(missing.path().join("absent"), &[]).expect("missing directory is not a child"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_dir_probe_bounds_unsupported_entries() {
|
||||
@@ -5720,6 +5814,7 @@ mod tests {
|
||||
|
||||
assert!(probe.entries.is_empty());
|
||||
assert!(!probe.complete, "a bounded probe must not claim that an oversized directory is complete");
|
||||
assert!(has_non_data_dir_entry(temp_dir.path(), &[]).expect("probe directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -336,7 +336,14 @@ impl InstanceContext {
|
||||
F::Output: Send + 'static,
|
||||
{
|
||||
// Capture before spawning: Tokio tasks do not inherit request task-locals.
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let mutation = crate::cluster::rpc::runtime_sources::with_captured_read_version_context(
|
||||
rustfs_io_metrics::internode_metrics::ReadVersionContextBoundary::DetachedMutation,
|
||||
mutation,
|
||||
);
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
let context = rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context();
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
let mutation = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(context, mutation);
|
||||
self.detached_mutations.spawn(mutation.in_current_span()).await
|
||||
}
|
||||
@@ -1109,6 +1116,60 @@ mod tests {
|
||||
assert!(ctx.wait_for_detached_mutations(std::time::Duration::from_secs(5)).await);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[tokio::test]
|
||||
async fn detached_mutations_keep_existing_context_after_waiter_drop_and_nested_spawn() {
|
||||
use crate::cluster::rpc::runtime_sources::{
|
||||
current_read_version_entry_source, current_read_version_request_caller, with_read_version_entry,
|
||||
with_read_version_request_caller,
|
||||
};
|
||||
use rustfs_io_metrics::internode_metrics::{ReadVersionEntrySource, ReadVersionRequestCaller};
|
||||
|
||||
let ctx = Arc::new(InstanceContext::new());
|
||||
let child_ctx = Arc::clone(&ctx);
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
|
||||
let mut caller = Box::pin(with_read_version_request_caller(ReadVersionRequestCaller::Object, async {
|
||||
with_read_version_entry(ReadVersionEntrySource::LifecycleRecovery, async {
|
||||
ctx.run_detached_mutation(async move {
|
||||
entered_tx.send(()).expect("mutation caller is present");
|
||||
release_rx.await.expect("mutation may finish");
|
||||
let observed = with_read_version_entry(ReadVersionEntrySource::ConfigRead, async {
|
||||
child_ctx
|
||||
.run_detached_mutation(async {
|
||||
(current_read_version_request_caller(), current_read_version_entry_source())
|
||||
})
|
||||
.await
|
||||
.expect("nested mutation joins")
|
||||
})
|
||||
.await;
|
||||
finished_tx.send(observed).expect("completion observer remains live");
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}));
|
||||
assert!(futures::poll!(caller.as_mut()).is_pending());
|
||||
entered_rx.await.expect("mutation entered");
|
||||
drop(caller);
|
||||
assert_eq!(current_read_version_request_caller(), None);
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
assert_eq!(ctx.detached_mutation_count(), 1);
|
||||
release_tx.send(()).expect("mutation remains alive after waiter drop");
|
||||
assert_eq!(
|
||||
finished_rx.await.expect("nested mutation completes"),
|
||||
(Some(ReadVersionRequestCaller::Object), Some(ReadVersionEntrySource::LifecycleRecovery))
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.run_detached_mutation(async { (current_read_version_request_caller(), current_read_version_entry_source()) })
|
||||
.await
|
||||
.expect("unscoped mutation joins"),
|
||||
(None, None)
|
||||
);
|
||||
assert!(ctx.wait_for_detached_mutations(std::time::Duration::from_secs(5)).await);
|
||||
}
|
||||
|
||||
// A fresh context (before any update) reflects the initial all-false state.
|
||||
#[tokio::test]
|
||||
async fn fresh_context_is_all_false() {
|
||||
|
||||
@@ -6557,6 +6557,16 @@ fn should_force_delete_marker_for_missing_version(opts: &ObjectOptions) -> bool
|
||||
opts.delete_marker || ((opts.versioned || opts.version_suspended) && opts.version_id.is_none() && !opts.data_movement)
|
||||
}
|
||||
|
||||
fn republishes_data_movement_delete_marker(opts: &ObjectOptions, goi: &ObjectInfo, version_found: bool) -> bool {
|
||||
opts.data_movement
|
||||
&& opts.delete_marker
|
||||
&& version_found
|
||||
&& goi.delete_marker
|
||||
&& opts.version_purge_status().is_empty()
|
||||
&& goi.version_purge_status.is_empty()
|
||||
&& opts.version_id.as_deref().and_then(|id| Uuid::parse_str(id).ok()) == Some(goi.version_id.unwrap_or_else(Uuid::nil))
|
||||
}
|
||||
|
||||
/// Whether a plain client delete addressed to an explicit version removed an
|
||||
/// existing delete marker, so the response must carry delete-marker semantics
|
||||
/// (`x-amz-delete-marker: true` / `DeleteMarker` in `DeleteObjects` entries).
|
||||
@@ -6572,6 +6582,7 @@ fn should_force_delete_marker_for_missing_version(opts: &ObjectOptions) -> bool
|
||||
/// `resolve_delete_version_state` that clear `delete_marker` for them.
|
||||
fn explicit_delete_removed_marker(opts: &ObjectOptions, goi: &ObjectInfo, version_found: bool) -> bool {
|
||||
opts.version_id.is_some()
|
||||
&& !republishes_data_movement_delete_marker(opts, goi, version_found)
|
||||
&& version_found
|
||||
&& goi.delete_marker
|
||||
&& goi.version_purge_status.is_empty()
|
||||
@@ -6580,6 +6591,9 @@ fn explicit_delete_removed_marker(opts: &ObjectOptions, goi: &ObjectInfo, versio
|
||||
}
|
||||
|
||||
fn resolve_delete_version_state(opts: &ObjectOptions, goi: &ObjectInfo, version_found: bool) -> (bool, bool) {
|
||||
if republishes_data_movement_delete_marker(opts, goi, version_found) {
|
||||
return (true, true);
|
||||
}
|
||||
let mut mark_delete = goi.version_id.is_some() || ((opts.versioned || opts.version_suspended) && opts.version_id.is_none());
|
||||
let mut delete_marker = opts.versioned;
|
||||
|
||||
@@ -6634,6 +6648,29 @@ fn resolve_delete_version_state(opts: &ObjectOptions, goi: &ObjectInfo, version_
|
||||
}
|
||||
|
||||
impl SetDisks {
|
||||
pub(crate) async fn verify_data_movement_delete_marker_write_quorum(
|
||||
&self,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
version_id: &str,
|
||||
) -> Result<()> {
|
||||
let requested_version = Uuid::parse_str(version_id)?;
|
||||
let disks = self.disk_inventory().await;
|
||||
let (metadata, errors) = Self::read_all_fileinfo(&disks, "", bucket, object, version_id, false, false, false).await?;
|
||||
// Match delete_object_version_with_purge, independently of data parity or online inventory size.
|
||||
let quorum = self.set_drive_count / 2 + 1;
|
||||
let (_, marker, _) = Self::select_valid_fileinfo(&disks, &metadata, &errors, version_id, quorum, quorum)
|
||||
.map_err(|error| to_object_err(error.into(), vec![bucket, object, version_id]))?;
|
||||
if !marker.deleted || marker.tier_free_version() || marker.version_id.unwrap_or_else(Uuid::nil) != requested_version {
|
||||
return Err(Error::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
version_id.to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish an internal tier free-version record without changing its
|
||||
/// delete-marker shape or remote-tier identity. The caller holds the
|
||||
/// source and target object locks; a write quorum is required before the
|
||||
@@ -9138,6 +9175,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_marker_republication_preserves_creation_and_exact_identity() {
|
||||
for version in [Uuid::nil(), Uuid::new_v4()] {
|
||||
for replica in [false, true] {
|
||||
let opts = ObjectOptions {
|
||||
versioned: !version.is_nil(),
|
||||
version_suspended: version.is_nil(),
|
||||
version_id: Some(version.to_string()),
|
||||
data_movement: true,
|
||||
delete_marker: true,
|
||||
delete_replication: replica.then(|| ReplicationState {
|
||||
replica_status: ReplicationStatusType::Replica,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let current = ObjectInfo {
|
||||
version_id: (!version.is_nil()).then_some(version),
|
||||
delete_marker: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_delete_version_state(&opts, ¤t, true), (true, true));
|
||||
assert!(!explicit_delete_removed_marker(&opts, ¤t, true));
|
||||
let mut changed = current.clone();
|
||||
changed.version_id = Some(Uuid::new_v4());
|
||||
assert!(!republishes_data_movement_delete_marker(&opts, &changed, true));
|
||||
changed.version_id = current.version_id;
|
||||
changed.delete_marker = false;
|
||||
assert!(!republishes_data_movement_delete_marker(&opts, &changed, true));
|
||||
changed = current.clone();
|
||||
changed.version_purge_status = VersionPurgeStatusType::Complete;
|
||||
assert!(!republishes_data_movement_delete_marker(&opts, &changed, true));
|
||||
let purging = ObjectOptions {
|
||||
delete_replication: Some(ReplicationState {
|
||||
version_purge_status_internal: Some("target=PENDING;".to_string()),
|
||||
purge_targets: version_purge_statuses_map("target=PENDING;"),
|
||||
..Default::default()
|
||||
}),
|
||||
..opts.clone()
|
||||
};
|
||||
assert!(!republishes_data_movement_delete_marker(&purging, ¤t, true));
|
||||
let ordinary = ObjectOptions {
|
||||
data_movement: false,
|
||||
..opts
|
||||
};
|
||||
assert!(!republishes_data_movement_delete_marker(&ordinary, ¤t, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_delete_version_state_keeps_delete_marker_for_replica_marker_creation() {
|
||||
let opts = ObjectOptions {
|
||||
|
||||
@@ -58,11 +58,12 @@ use super::super::{
|
||||
record_get_codec_streaming_gate_decision, record_get_direct_memory_decision, record_get_object_pipeline_failure,
|
||||
record_get_object_pipeline_failure_for_path, record_get_object_reader_path_observation, record_get_stage_duration_if_enabled,
|
||||
record_lock_acquire, reduce_write_quorum_errs, release_materialized_read_lock, replication_write_may_pass_worm_gate,
|
||||
require_restore_operation_id, resolve_delete_version_state, resolve_tiered_decommission_write_quorum_result,
|
||||
resolve_write_layout, restore_commit_operation_id_from_metadata, restore_operation_id_from_metadata, send_event,
|
||||
set_disk_delete_creates_delete_marker, should_force_delete_marker_for_missing_version,
|
||||
should_persist_encryption_original_size, should_preserve_delete_replication_state, should_use_inline_fast_path_with_plan,
|
||||
take_prepared_get_object_metadata, to_object_err, try_read_inline_data_shards_direct, warn,
|
||||
republishes_data_movement_delete_marker, require_restore_operation_id, resolve_delete_version_state,
|
||||
resolve_tiered_decommission_write_quorum_result, resolve_write_layout, restore_commit_operation_id_from_metadata,
|
||||
restore_operation_id_from_metadata, send_event, set_disk_delete_creates_delete_marker,
|
||||
should_force_delete_marker_for_missing_version, should_persist_encryption_original_size,
|
||||
should_preserve_delete_replication_state, should_use_inline_fast_path_with_plan, take_prepared_get_object_metadata,
|
||||
to_object_err, try_read_inline_data_shards_direct, warn,
|
||||
};
|
||||
use super::bitrot_self_verify::{BitrotSelfVerifyTarget, drop_failed_writer_disks, verify_written_bitrot_shards};
|
||||
use super::delete_recovery::DeleteRecoveryContext;
|
||||
@@ -10826,7 +10827,14 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
|
||||
fi.set_tier_free_version_id(&find_vid.to_string());
|
||||
|
||||
if let Some(incarnation) = opts.expected_bucket_incarnation_id {
|
||||
let marker_incarnation = if republishes_data_movement_delete_marker(&opts, &goi, version_found) {
|
||||
// Keep the existing marker's birth identity, including an
|
||||
// unknown legacy identity, identical on newly repaired disks.
|
||||
authoritative_file_info.delete_marker_incarnation()
|
||||
} else {
|
||||
opts.expected_bucket_incarnation_id
|
||||
};
|
||||
if let Some(incarnation) = marker_incarnation {
|
||||
fi.set_delete_marker_incarnation(incarnation);
|
||||
}
|
||||
|
||||
@@ -10857,6 +10865,12 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
||||
)
|
||||
.await
|
||||
.map_err(|e| to_object_err(e, vec![bucket, object]))?;
|
||||
if republishes_data_movement_delete_marker(&opts, &goi, version_found)
|
||||
&& let Some(version_id) = opts.version_id.as_deref()
|
||||
{
|
||||
self.verify_data_movement_delete_marker_write_quorum(bucket, object, version_id)
|
||||
.await?;
|
||||
}
|
||||
#[cfg(test)]
|
||||
pause_delete_object_commit_after_publish(bucket, object).await;
|
||||
|
||||
|
||||
@@ -7239,6 +7239,280 @@ mod tests {
|
||||
.expect("join data movement replacement test thread");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn data_movement_delete_marker_resume_repairs_read_quorum_only_target() {
|
||||
use crate::disk::DiskAPI;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create partial delete-marker target store");
|
||||
let (_ctx, store, _shutdown) = Box::pin(without_storage_class_env(build_isolated_test_store(
|
||||
temp_dir.path(),
|
||||
"partial-marker-target",
|
||||
&[4, 4],
|
||||
)))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("dm-partial-marker-{}", uuid::Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create partial marker bucket");
|
||||
let incarnation = store
|
||||
.bucket_incarnation_id(&bucket)
|
||||
.await
|
||||
.expect("fixture bucket incarnation");
|
||||
for (object, marker_id, marker_incarnation) in [
|
||||
("legacy-marker", uuid::Uuid::new_v4(), None),
|
||||
("versioned-object", uuid::Uuid::new_v4(), Some(incarnation)),
|
||||
("legacy-null-marker", uuid::Uuid::nil(), None),
|
||||
("null-marker", uuid::Uuid::nil(), Some(incarnation)),
|
||||
] {
|
||||
let marker_time = OffsetDateTime::now_utc();
|
||||
let marker_opts = ObjectOptions {
|
||||
versioned: !marker_id.is_nil(),
|
||||
version_suspended: marker_id.is_nil(),
|
||||
data_movement: true,
|
||||
version_id: Some(marker_id.to_string()),
|
||||
delete_marker: true,
|
||||
mod_time: Some(marker_time),
|
||||
expected_bucket_incarnation_id: marker_incarnation,
|
||||
..Default::default()
|
||||
};
|
||||
store.pools[0]
|
||||
.delete_object(&bucket, object, marker_opts.clone())
|
||||
.await
|
||||
.expect("seed the source delete marker");
|
||||
let old_version = uuid::Uuid::new_v4();
|
||||
store.pools[1]
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"historical target body".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(old_version.to_string()),
|
||||
mod_time: Some(marker_time - time::Duration::SECOND),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed older target metadata on all disks");
|
||||
let source = store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("read source marker metadata")
|
||||
.expect("source marker must exist")
|
||||
.versions
|
||||
.into_iter()
|
||||
.find(|version| version.version_id.unwrap_or_default() == marker_id)
|
||||
.expect("find source delete marker");
|
||||
let target_set = store.pools[1].get_disks_by_key(object);
|
||||
for disk in target_set.disk_inventory().await.into_iter().flatten().take(2) {
|
||||
disk.write_metadata("", &bucket, object, source.clone())
|
||||
.await
|
||||
.expect("seed a read-quorum-only target marker");
|
||||
}
|
||||
target_set
|
||||
.get_object_info(&bucket, object, &marker_opts)
|
||||
.await
|
||||
.expect("the exact marker is visible at read quorum");
|
||||
store
|
||||
.delete_object(
|
||||
&bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
data_movement: true,
|
||||
src_pool_idx: 0,
|
||||
..marker_opts.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("resume the exact delete-marker copy");
|
||||
for disk in target_set.disk_inventory().await.into_iter().flatten() {
|
||||
let marker = disk
|
||||
.read_version("", &bucket, object, &marker_id.to_string(), &crate::disk::ReadOptions::default())
|
||||
.await
|
||||
.expect("resumed marker must be published on every healthy target disk");
|
||||
assert!(marker.deleted);
|
||||
assert_eq!(marker.delete_marker_incarnation(), marker_incarnation);
|
||||
}
|
||||
let latest = target_set
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("latest lookup must resolve the resumed marker");
|
||||
assert!(latest.delete_marker);
|
||||
assert_eq!(latest.mod_time, Some(marker_time));
|
||||
let mut historical = target_set
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(old_version.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("marker repair must retain the target's older version");
|
||||
let mut body = Vec::new();
|
||||
historical
|
||||
.stream
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.expect("read older target body");
|
||||
assert_eq!(body, b"historical target body");
|
||||
let retained = store.pools[0]
|
||||
.get_object_info(&bucket, object, &marker_opts)
|
||||
.await
|
||||
.expect("marker retry must retain the source");
|
||||
assert!(retained.delete_marker);
|
||||
assert_eq!(retained.version_id.unwrap_or_default(), marker_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn data_movement_delete_marker_resume_uses_configured_marker_majority() {
|
||||
use crate::disk::DiskAPI;
|
||||
|
||||
let temp_dir = tempfile::tempdir().expect("create degraded marker target store");
|
||||
let (_ctx, store, _shutdown) = Box::pin(without_storage_class_env(build_isolated_test_store(
|
||||
temp_dir.path(),
|
||||
"marker-majority-target",
|
||||
&[12, 12],
|
||||
)))
|
||||
.await;
|
||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = format!("dm-marker-majority-{}", uuid::Uuid::new_v4());
|
||||
store
|
||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("create marker majority bucket");
|
||||
let incarnation = store.bucket_incarnation_id(&bucket).await.expect("fixture incarnation");
|
||||
for (object, marker_id) in [("named-marker", uuid::Uuid::new_v4()), ("null-marker", uuid::Uuid::nil())] {
|
||||
let marker_time = OffsetDateTime::now_utc();
|
||||
let old_version = uuid::Uuid::new_v4();
|
||||
store.pools[0]
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut PutObjReader::from_vec(b"historical source body".to_vec()),
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
version_id: Some(old_version.to_string()),
|
||||
mod_time: Some(marker_time - time::Duration::SECOND),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("seed an older source version");
|
||||
let marker_opts = ObjectOptions {
|
||||
versioned: !marker_id.is_nil(),
|
||||
version_suspended: marker_id.is_nil(),
|
||||
data_movement: true,
|
||||
version_id: Some(marker_id.to_string()),
|
||||
delete_marker: true,
|
||||
mod_time: Some(marker_time),
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
store.pools[0]
|
||||
.delete_object(&bucket, object, marker_opts.clone())
|
||||
.await
|
||||
.expect("seed the source marker");
|
||||
let source = store.pools[0]
|
||||
.get_disks_by_key(object)
|
||||
.load_file_info_versions_exact(&bucket, object)
|
||||
.await
|
||||
.expect("read source versions")
|
||||
.expect("source versions must exist")
|
||||
.versions
|
||||
.into_iter()
|
||||
.find(|version| version.version_id.unwrap_or_default() == marker_id)
|
||||
.expect("find the source marker");
|
||||
let target_set = store.pools[1].get_disks_by_key(object);
|
||||
assert_eq!(target_set.default_write_quorum(), 8, "the fixture must distinguish EC4 data quorum");
|
||||
let original_disks = target_set.disk_inventory().await;
|
||||
for disk in original_disks.iter().take(7).flatten() {
|
||||
disk.write_metadata("", &bucket, object, source.clone())
|
||||
.await
|
||||
.expect("seed a marker majority");
|
||||
}
|
||||
let mut degraded_disks = original_disks.clone();
|
||||
degraded_disks[7..].fill(None);
|
||||
*target_set.disks.write().await = degraded_disks;
|
||||
let latest = target_set
|
||||
.get_object_info(&bucket, object, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("seven target markers must resolve the latest version");
|
||||
assert!(latest.delete_marker);
|
||||
assert_eq!(latest.version_id.unwrap_or_default(), marker_id);
|
||||
*store.rebalance_meta.write().await = Some(active_rebalance_meta_for_pool(2, 0));
|
||||
let resumed = store
|
||||
.delete_object(
|
||||
&bucket,
|
||||
object,
|
||||
ObjectOptions {
|
||||
src_pool_idx: 0,
|
||||
..marker_opts.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("rebalance retry must accept seven matching markers in a twelve-drive set");
|
||||
assert!(resumed.delete_marker);
|
||||
assert_eq!(resumed.version_id.unwrap_or_default(), marker_id);
|
||||
assert_eq!(resumed.mod_time, Some(marker_time));
|
||||
for shortened_snapshot in [false, true] {
|
||||
{
|
||||
let mut disks = target_set.disks.write().await;
|
||||
disks[6] = None;
|
||||
if shortened_snapshot {
|
||||
disks.truncate(6);
|
||||
}
|
||||
}
|
||||
let error = target_set
|
||||
.verify_data_movement_delete_marker_write_quorum(&bucket, object, &marker_id.to_string())
|
||||
.await
|
||||
.expect_err("six markers must remain below the configured majority");
|
||||
assert!(
|
||||
matches!(error, StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _)),
|
||||
"unexpected subquorum marker error: {error:?}"
|
||||
);
|
||||
}
|
||||
*target_set.disks.write().await = original_disks;
|
||||
*store.rebalance_meta.write().await = None;
|
||||
let retained = store.pools[0]
|
||||
.get_object_info(&bucket, object, &marker_opts)
|
||||
.await
|
||||
.expect("resumed copy must retain the source marker");
|
||||
assert!(retained.delete_marker);
|
||||
assert_eq!(retained.version_id.unwrap_or_default(), marker_id);
|
||||
let mut historical = store.pools[0]
|
||||
.get_object_reader(
|
||||
&bucket,
|
||||
object,
|
||||
None,
|
||||
HeaderMap::new(),
|
||||
&ObjectOptions {
|
||||
version_id: Some(old_version.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("resumed copy must retain the older source version");
|
||||
let mut body = Vec::new();
|
||||
historical
|
||||
.stream
|
||||
.read_to_end(&mut body)
|
||||
.await
|
||||
.expect("read older source body");
|
||||
assert_eq!(body, b"historical source body");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial(storage_class_env)]
|
||||
async fn data_movement_delete_marker_retries_converge_safely() {
|
||||
|
||||
@@ -3516,9 +3516,7 @@ 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.
|
||||
#[cfg(feature = "test-util")]
|
||||
pub async fn save_scanner_pause_backlog_replica(
|
||||
self: &Arc<Self>,
|
||||
pool_index: usize,
|
||||
@@ -3526,6 +3524,34 @@ impl ECStore {
|
||||
data: Vec<u8>,
|
||||
preconditions: crate::storage_api_contracts::object::HTTPPreconditions,
|
||||
) -> Result<ObjectInfo> {
|
||||
self.save_scanner_pause_backlog_replica_for_membership(
|
||||
pool_index,
|
||||
set_index,
|
||||
data,
|
||||
preconditions,
|
||||
Arc::from([pool_index]),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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_for_membership(
|
||||
self: &Arc<Self>,
|
||||
pool_index: usize,
|
||||
set_index: usize,
|
||||
data: Vec<u8>,
|
||||
preconditions: crate::storage_api_contracts::object::HTTPPreconditions,
|
||||
writable_pool_indices: Arc<[usize]>,
|
||||
) -> Result<ObjectInfo> {
|
||||
if !writable_pool_indices.contains(&pool_index) {
|
||||
return Err(Error::InvalidArgument(
|
||||
"scanner-backlog".into(),
|
||||
"membership".into(),
|
||||
"replica is outside the publication cohort".into(),
|
||||
));
|
||||
}
|
||||
let set = self
|
||||
.pools
|
||||
.get(pool_index)
|
||||
@@ -3550,6 +3576,9 @@ impl ECStore {
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
// Fence every member of the captured publication cohort. After
|
||||
// retirement, a stale fan-out must not stabilize its old record on
|
||||
// still-writable targets and invalidate their native rollback point.
|
||||
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()) {
|
||||
@@ -3557,7 +3586,10 @@ impl ECStore {
|
||||
None => false,
|
||||
};
|
||||
let capacity_guard = store
|
||||
.acquire_external_decommission_capacity_fence(&[pool_index], DecommissionCapacityAdmission::ScannerBacklog)
|
||||
.acquire_external_decommission_capacity_fence(
|
||||
&writable_pool_indices,
|
||||
DecommissionCapacityAdmission::ScannerBacklog,
|
||||
)
|
||||
.await?;
|
||||
opts.add_namespace_lock_guard(&capacity_guard);
|
||||
Some(capacity_guard)
|
||||
@@ -5457,20 +5489,44 @@ impl ECStore {
|
||||
.find_data_movement_target_info(bucket, object, target_pool_idx, &target_opts)
|
||||
.await?;
|
||||
if let Some(target) = target {
|
||||
if is_equivalent_data_movement_delete_marker(&source, &target) {
|
||||
if let Some(owner) = DecommissionCapacityOwner::from_options(&target_opts) {
|
||||
self.reconcile_decommission_capacity_after_equivalent_target(owner, target_pool_idx, 0)
|
||||
.await?;
|
||||
}
|
||||
let mut target = target;
|
||||
target.name = decode_dir_object(object);
|
||||
return Ok(target);
|
||||
if !is_equivalent_data_movement_delete_marker(&source, &target) {
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
return Err(StorageError::DataMovementOverwriteErr(
|
||||
bucket.to_owned(),
|
||||
object.to_owned(),
|
||||
opts.version_id.unwrap_or_default(),
|
||||
));
|
||||
let marker_version_id = target_opts
|
||||
.version_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| StorageError::other("data movement marker verification requires an exact version id"))?;
|
||||
let confirmation = self.pools[target_pool_idx]
|
||||
.get_disks_by_key(object)
|
||||
.verify_data_movement_delete_marker_write_quorum(bucket, object, marker_version_id)
|
||||
.await;
|
||||
match confirmation {
|
||||
Ok(()) => {
|
||||
if let Some(owner) = DecommissionCapacityOwner::from_options(&target_opts) {
|
||||
self.reconcile_decommission_capacity_after_equivalent_target(owner, target_pool_idx, 0)
|
||||
.await?;
|
||||
}
|
||||
let mut target = target;
|
||||
target.name = decode_dir_object(object);
|
||||
return Ok(target);
|
||||
}
|
||||
Err(error)
|
||||
if matches!(error, StorageError::ErasureReadQuorum | StorageError::InsufficientReadQuorum(_, _))
|
||||
&& target_opts.version_purge_status().is_empty()
|
||||
&& target.version_purge_status.is_empty() => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
if let Some(owner) = DecommissionCapacityOwner::from_options(&target_opts) {
|
||||
self.reconcile_decommission_capacity_after_equivalent_target(owner, target_pool_idx, 0)
|
||||
.await?;
|
||||
}
|
||||
// An exact-version read proves only read quorum. Republish
|
||||
// the same marker so retries restore its write quorum before
|
||||
// a later latest-version lookup or source cleanup can proceed.
|
||||
}
|
||||
delete_marker_target_opts = Some(target_opts);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// GCS-enabled ECStore futures need the same layout recursion budget as the library.
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod storage_api;
|
||||
|
||||
use rustfs_filemeta::{FileInfoOpts, get_file_info};
|
||||
|
||||
@@ -65,6 +65,10 @@ proptest = "1"
|
||||
name = "xl_meta_bench"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "rename_data_meta_benchmark"
|
||||
harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
+63
-3
@@ -12,8 +12,9 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use bytes::Bytes;
|
||||
use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||
use rustfs_filemeta::{ErasureAlgo, FileInfo, FileMeta};
|
||||
use rustfs_filemeta::{ErasureAlgo, FileInfo, FileMeta, ObjectPartInfo};
|
||||
use std::hint::black_box;
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
@@ -55,7 +56,8 @@ fn build_meta_with_versions(version_count: usize) -> FileMeta {
|
||||
fn bench_rename_data_meta_path(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("rename_data_meta");
|
||||
group.sample_size(20);
|
||||
group.measurement_time(Duration::from_secs(10));
|
||||
group.warm_up_time(Duration::from_millis(300));
|
||||
group.measurement_time(Duration::from_secs(1));
|
||||
|
||||
for &version_count in VERSION_COUNT_CASES {
|
||||
let seeded = build_meta_with_versions(version_count);
|
||||
@@ -67,6 +69,10 @@ fn bench_rename_data_meta_path(c: &mut Criterion) {
|
||||
.and_then(|v| v.header.version_id)
|
||||
.unwrap_or(Uuid::nil());
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("find_unshared_data_dir", version_count), &version_count, |b, _| {
|
||||
b.iter(|| black_box(seeded.find_unshared_data_dir_for_version(black_box(Some(replace_version_id)))));
|
||||
});
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("read_modify_write", version_count), &version_count, |b, _| {
|
||||
b.iter(|| {
|
||||
let mut xlmeta = FileMeta::load(black_box(&dst_buf)).expect("load dst meta");
|
||||
@@ -114,5 +120,59 @@ fn bench_rename_data_meta_path(c: &mut Criterion) {
|
||||
}
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_rename_data_meta_path);
|
||||
fn bench_delete_version_batches(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("delete_version_batch");
|
||||
group.sample_size(20);
|
||||
group.warm_up_time(Duration::from_millis(300));
|
||||
group.measurement_time(Duration::from_secs(1));
|
||||
for (kind, inline_size) in [
|
||||
("physical", None),
|
||||
("inline-64", Some(64_usize)),
|
||||
("inline-9k", Some(9 * 1024)),
|
||||
] {
|
||||
for (versions, requested) in [(1_usize, 1), (8, 8), (32, 32), (128, 128), (1000, 1000), (1000, 32)] {
|
||||
let mut meta = FileMeta::new();
|
||||
let base_time = OffsetDateTime::from_unix_timestamp(BENCH_BASE_TIME_UNIX_SECS).expect("fixture timestamp");
|
||||
for id in 1..=versions {
|
||||
let size = inline_size.unwrap_or(64 * 1024);
|
||||
let mut info = make_file_info(
|
||||
Uuid::from_u128(u128::try_from(id).expect("version id")),
|
||||
Uuid::from_u128(u128::try_from(versions + id).expect("directory id")),
|
||||
i64::try_from(size).expect("object size"),
|
||||
base_time + time::Duration::seconds(i64::try_from(id).expect("version time")),
|
||||
);
|
||||
info.parts.push(ObjectPartInfo {
|
||||
number: 1,
|
||||
size,
|
||||
actual_size: info.size,
|
||||
..Default::default()
|
||||
});
|
||||
if inline_size.is_some() {
|
||||
info.set_inline_data();
|
||||
info.data = Some(Bytes::from(vec![42; size]));
|
||||
}
|
||||
meta.add_version(info).expect("seed batch metadata");
|
||||
}
|
||||
let requests: Vec<_> = meta
|
||||
.versions
|
||||
.iter()
|
||||
.take(requested)
|
||||
.map(|version| FileInfo {
|
||||
version_id: version.header.version_id,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
group.bench_with_input(BenchmarkId::new(format!("{kind}-r{requested}"), versions), &versions, |b, _| {
|
||||
b.iter_batched(
|
||||
|| meta.clone(),
|
||||
|mut metadata| black_box(metadata.delete_versions(black_box(&requests)).expect("delete batch")),
|
||||
BatchSize::LargeInput,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_rename_data_meta_path, bench_delete_version_batches);
|
||||
criterion_main!(benches);
|
||||
@@ -204,6 +204,97 @@ fn bench_delete_inline_versions(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_metacache_stream_page(c: &mut Criterion) {
|
||||
let xl_bytes = create_real_xlmeta().expect("create_real_xlmeta failed");
|
||||
let mut entries = Vec::with_capacity(2000);
|
||||
for i in 0..1000 {
|
||||
entries.push(MetaCacheEntry {
|
||||
name: format!("prefix/subprefix/object-{i:04}"),
|
||||
metadata: xl_bytes.clone(),
|
||||
cached: None,
|
||||
reusable: false,
|
||||
});
|
||||
entries.push(MetaCacheEntry {
|
||||
name: format!("prefix/subprefix/object-{i:04}/"),
|
||||
metadata: Vec::new(),
|
||||
cached: None,
|
||||
reusable: false,
|
||||
});
|
||||
}
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("build current_thread runtime");
|
||||
|
||||
let encoded_stream = rt.block_on(async {
|
||||
let mut cursor = std::io::Cursor::new(Vec::with_capacity(512 * 1024));
|
||||
let mut writer = rustfs_filemeta::MetacacheWriter::new(&mut cursor);
|
||||
writer.write(&entries).await.expect("encode entries");
|
||||
writer.close().await.expect("close stream");
|
||||
cursor.into_inner()
|
||||
});
|
||||
|
||||
let mut group = c.benchmark_group("metacache_stream_page");
|
||||
|
||||
group.bench_function("reader_read_all/1000_objects", |b| {
|
||||
b.iter(|| {
|
||||
rt.block_on(async {
|
||||
let mut reader =
|
||||
rustfs_filemeta::MetacacheReader::new(std::io::Cursor::new(black_box(encoded_stream.as_slice())));
|
||||
let out = reader.read_all().await.expect("read_all");
|
||||
black_box(out.len());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("duplex_4k_write_and_read/1000_objects", |b| {
|
||||
b.iter(|| {
|
||||
rt.block_on(async {
|
||||
let (rd, mut wr) = tokio::io::duplex(4096);
|
||||
let entries_ref = &entries;
|
||||
let writer_fut = async move {
|
||||
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
|
||||
for chunk in entries_ref.chunks(64) {
|
||||
out.write(chunk).await.expect("write");
|
||||
}
|
||||
out.close().await.expect("close");
|
||||
};
|
||||
let reader_fut = async move {
|
||||
let mut reader = rustfs_filemeta::MetacacheReader::new(rd);
|
||||
let out = reader.read_all().await.expect("read_all");
|
||||
black_box(out.len());
|
||||
};
|
||||
tokio::join!(writer_fut, reader_fut);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("duplex_4k_write_obj_and_read/1000_objects", |b| {
|
||||
b.iter(|| {
|
||||
rt.block_on(async {
|
||||
let (rd, mut wr) = tokio::io::duplex(4096);
|
||||
let entries_ref = &entries;
|
||||
let writer_fut = async move {
|
||||
let mut out = rustfs_filemeta::MetacacheWriter::new(&mut wr);
|
||||
for entry in entries_ref {
|
||||
out.write_obj(entry).await.expect("write_obj");
|
||||
}
|
||||
out.close().await.expect("close");
|
||||
};
|
||||
let reader_fut = async move {
|
||||
let mut reader = rustfs_filemeta::MetacacheReader::new(rd);
|
||||
let out = reader.read_all().await.expect("read_all");
|
||||
black_box(out.len());
|
||||
};
|
||||
tokio::join!(writer_fut, reader_fut);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_create_real_xlmeta,
|
||||
@@ -218,7 +309,8 @@ criterion_group!(
|
||||
bench_validate_integrity,
|
||||
bench_into_fileinfo_realistic,
|
||||
bench_list_page_to_fileinfo,
|
||||
bench_delete_inline_versions
|
||||
bench_delete_inline_versions,
|
||||
bench_metacache_stream_page
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -1514,34 +1514,31 @@ fn parse_restore_expiry_date(value: &str) -> Result<OffsetDateTime> {
|
||||
}
|
||||
|
||||
pub fn parse_restore_obj_status(restore_hdr: &str) -> Result<RestoreStatus> {
|
||||
let tokens: Vec<&str> = restore_hdr.splitn(2, ",").collect();
|
||||
let progress_tokens: Vec<&str> = tokens[0].splitn(2, "=").collect();
|
||||
if progress_tokens.len() != 2 {
|
||||
return Err(Error::other(ERR_RESTORE_HDR_MALFORMED));
|
||||
}
|
||||
if progress_tokens[0].trim() != "ongoing-request" {
|
||||
let (progress, expiry_field) = restore_hdr
|
||||
.split_once(',')
|
||||
.map_or((restore_hdr, None), |(progress, expiry)| (progress, Some(expiry)));
|
||||
let (progress_key, progress_value) = progress
|
||||
.split_once('=')
|
||||
.ok_or_else(|| Error::other(ERR_RESTORE_HDR_MALFORMED))?;
|
||||
if progress_key.trim() != "ongoing-request" {
|
||||
return Err(Error::other(ERR_RESTORE_HDR_MALFORMED));
|
||||
}
|
||||
|
||||
match progress_tokens[1] {
|
||||
"true" | "\"true\"" if tokens.len() == 1 => {
|
||||
match progress_value {
|
||||
"true" | "\"true\"" if expiry_field.is_none() => {
|
||||
return Ok(RestoreStatus {
|
||||
is_restore_in_progress: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
"false" | "\"false\"" => {
|
||||
if tokens.len() != 2 {
|
||||
let (expiry_key, expiry_value) = expiry_field
|
||||
.and_then(|field| field.split_once('='))
|
||||
.ok_or_else(|| Error::other(ERR_RESTORE_HDR_MALFORMED))?;
|
||||
if expiry_key.trim() != "expiry-date" {
|
||||
return Err(Error::other(ERR_RESTORE_HDR_MALFORMED));
|
||||
}
|
||||
let expiry_tokens: Vec<&str> = tokens[1].splitn(2, "=").collect();
|
||||
if expiry_tokens.len() != 2 {
|
||||
return Err(Error::other(ERR_RESTORE_HDR_MALFORMED));
|
||||
}
|
||||
if expiry_tokens[0].trim() != "expiry-date" {
|
||||
return Err(Error::other(ERR_RESTORE_HDR_MALFORMED));
|
||||
}
|
||||
let expiry = parse_restore_expiry_date(expiry_tokens[1].trim_matches('"'))?;
|
||||
let expiry = parse_restore_expiry_date(expiry_value.trim_matches('"'))?;
|
||||
return Ok(RestoreStatus {
|
||||
is_restore_in_progress: Some(false),
|
||||
restore_expiry_date: Some(expiry),
|
||||
@@ -2906,6 +2903,26 @@ mod tests {
|
||||
assert!(is_restored_object_on_disk(&meta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_status_preserves_progress_and_field_boundaries() {
|
||||
for header in ["ongoing-request=true", " ongoing-request =\"true\""] {
|
||||
let status = parse_restore_obj_status(header).expect("accepted in-progress field shape");
|
||||
assert_eq!(status.is_restore_in_progress, Some(true));
|
||||
assert_eq!(status.restore_expiry_date, None);
|
||||
}
|
||||
for header in [
|
||||
"",
|
||||
"ongoing-request",
|
||||
"ongoing-request= true",
|
||||
"ongoing-request=\"true\",",
|
||||
"ongoing-request=\"true\", expiry-date=\"2025-01-01T10:20:30Z\"",
|
||||
"ongoing-request=\"false\", expiry-date",
|
||||
"ongoing-request=\"false\", expiry-date=\"2025-01-01T10:20:30Z\"=extra",
|
||||
] {
|
||||
assert!(parse_restore_obj_status(header).is_err(), "{header:?} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
/// Widening the accepted formats must not weaken malformed-input
|
||||
/// rejection: an unparseable or non-GMT-shaped expiry still fails closed.
|
||||
#[test]
|
||||
|
||||
@@ -503,7 +503,7 @@ impl FileMeta {
|
||||
if fi.version_id.is_some_and(|id| !id.is_nil()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let legacy_id = if contains_key_str(&fi.metadata, SUFFIX_TIER_FV_ID) {
|
||||
let legacy_id = if contains_key_str(&fi.metadata, rustfs_utils::http::SUFFIX_TIER_FV_ID) {
|
||||
Some(Uuid::parse_str(
|
||||
get_consistent_str(&fi.metadata, SUFFIX_TIER_FV_ID).ok_or(Error::FileCorrupt)?,
|
||||
)?)
|
||||
@@ -596,7 +596,9 @@ impl FileMeta {
|
||||
}
|
||||
|
||||
fn add_version_inner(&mut self, mut fi: FileInfo) -> Result<()> {
|
||||
rustfs_utils::http::remove_str(&mut fi.metadata, rustfs_utils::http::SUFFIX_TIER_FV_ID);
|
||||
if contains_key_str(&fi.metadata, rustfs_utils::http::SUFFIX_TIER_FV_ID) {
|
||||
rustfs_utils::http::remove_str(&mut fi.metadata, rustfs_utils::http::SUFFIX_TIER_FV_ID);
|
||||
}
|
||||
// empty version_id means "null" (versioning disabled/suspended)
|
||||
if fi.version_id.is_none() {
|
||||
fi.version_id = Some(Uuid::nil());
|
||||
@@ -640,6 +642,13 @@ impl FileMeta {
|
||||
};
|
||||
|
||||
if let Some(fidx) = existing_idx {
|
||||
if !version.free_version()
|
||||
&& let Ok(summary) = FileMetaVersion::inspect_v2_object_slice(&self.versions[fidx].meta)
|
||||
&& (!summary.has_reconciled_transition
|
||||
|| version.object.as_ref().is_none_or(|next| next.data_dir != summary.data_dir))
|
||||
{
|
||||
return self.set_idx(fidx, version);
|
||||
}
|
||||
let existing = self.versions[fidx].parse_version_meta()?;
|
||||
if existing.free_version() != version.free_version() {
|
||||
return Err(Error::other("cannot replace a free version with a non-free version"));
|
||||
@@ -703,6 +712,15 @@ impl FileMeta {
|
||||
/// Apply a disk deletion batch while compacting inline payloads only once.
|
||||
/// Missing non-marker requests retain the disk layer's idempotent semantics.
|
||||
pub fn delete_versions(&mut self, requests: &[FileInfo]) -> Result<Vec<Option<Uuid>>> {
|
||||
let index = inline_data::BatchDeleteIndex::new(self, requests);
|
||||
self.delete_versions_with_index(requests, index)
|
||||
}
|
||||
|
||||
fn delete_versions_with_index(
|
||||
&mut self,
|
||||
requests: &[FileInfo],
|
||||
mut index: Option<inline_data::BatchDeleteIndex>,
|
||||
) -> Result<Vec<Option<Uuid>>> {
|
||||
if requests.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -710,19 +728,40 @@ impl FileMeta {
|
||||
let mut removed_keys = std::collections::HashSet::new();
|
||||
let mut data_dirs = Vec::with_capacity(requests.len());
|
||||
let mut failure = None;
|
||||
let version_ok = self.data.version_ok();
|
||||
let mut uuid_buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
|
||||
for request in requests {
|
||||
// Later requests must observe earlier inline removals even though
|
||||
// the payload buffer is not rebuilt until the batch is complete.
|
||||
let has_inline_data = self.data.version_ok()
|
||||
&& (inline_keys.contains(data_key_for_version(request.version_id).as_bytes())
|
||||
|| legacy_data_key_for_version(request.version_id).is_some_and(|key| inline_keys.contains(key.as_bytes())));
|
||||
match self.delete_version_inner(request, Some(has_inline_data)) {
|
||||
let has_inline_data = version_ok && !inline_keys.is_empty() && {
|
||||
let vid = request.version_id.unwrap_or_default();
|
||||
if vid.is_nil() {
|
||||
inline_keys.contains(NULL_VERSION_ID.as_bytes())
|
||||
|| inline_keys.contains(Uuid::nil().hyphenated().encode_lower(&mut uuid_buf).as_bytes())
|
||||
} else {
|
||||
inline_keys.contains(vid.hyphenated().encode_lower(&mut uuid_buf).as_bytes())
|
||||
}
|
||||
};
|
||||
let indexed = index
|
||||
.as_mut()
|
||||
.map(|index| index.delete(&self.versions, request, has_inline_data));
|
||||
let result = match indexed {
|
||||
Some(Ok(inline_data::IndexedDelete::Applied(dir))) => Ok(dir),
|
||||
Some(Err(error)) => Err(error),
|
||||
_ => {
|
||||
if let Some(index) = index.take() {
|
||||
index.finish(&mut self.versions);
|
||||
}
|
||||
self.delete_version_inner(request, Some(has_inline_data))
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(data_dir) => {
|
||||
if let Some(dir) = data_dir {
|
||||
for key in [request.version_id.unwrap_or_default(), dir] {
|
||||
let key = key.to_string().into_bytes();
|
||||
if inline_keys.remove(&key) {
|
||||
removed_keys.insert(key);
|
||||
let key_bytes = key.hyphenated().encode_lower(&mut uuid_buf).as_bytes();
|
||||
if inline_keys.remove(key_bytes) {
|
||||
removed_keys.insert(key_bytes.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -735,6 +774,9 @@ impl FileMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(index) = index {
|
||||
index.finish(&mut self.versions);
|
||||
}
|
||||
self.data.remove_keys(&removed_keys)?;
|
||||
if let Some(err) = failure {
|
||||
return Err(err);
|
||||
@@ -767,12 +809,6 @@ impl FileMeta {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let target_is_delete_marker = self
|
||||
.versions
|
||||
.iter()
|
||||
.find(|ver| ver.header.version_id == vid)
|
||||
.is_some_and(|ver| ver.header.version_type == VersionType::Delete);
|
||||
|
||||
let mut ventry = FileMetaVersion::default();
|
||||
if fi.deleted {
|
||||
ventry.version_type = VersionType::Delete;
|
||||
@@ -811,7 +847,14 @@ impl FileMeta {
|
||||
}
|
||||
}
|
||||
|
||||
if target_is_delete_marker && !fi.deleted && !fi.version_purge_status().is_empty() {
|
||||
if !fi.deleted
|
||||
&& !fi.version_purge_status().is_empty()
|
||||
&& self
|
||||
.versions
|
||||
.iter()
|
||||
.find(|ver| ver.header.version_id == vid)
|
||||
.is_some_and(|ver| ver.header.version_type == VersionType::Delete)
|
||||
{
|
||||
update_version = false;
|
||||
}
|
||||
|
||||
@@ -937,6 +980,21 @@ impl FileMeta {
|
||||
return Err(Error::FileVersionNotFound);
|
||||
};
|
||||
|
||||
if !fi.expire_restored
|
||||
&& fi.transition_status != TRANSITION_COMPLETE
|
||||
&& let Ok(summary) = FileMetaVersion::inspect_v2_object_slice(&self.versions[i].meta)
|
||||
&& (!summary.transition_complete || fi.skip_tier_free_version())
|
||||
{
|
||||
self.versions.remove(i);
|
||||
if fi.deleted {
|
||||
self.add_version_filemata(ventry)?;
|
||||
}
|
||||
if self.shared_data_dir_count_with_inline_data(summary.version_id, summary.data_dir, has_inline_data, true) > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(summary.data_dir);
|
||||
}
|
||||
|
||||
let mut ver = self.get_idx(i)?;
|
||||
|
||||
let Some(obj) = &mut ver.object else {
|
||||
@@ -980,7 +1038,7 @@ impl FileMeta {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
if self.shared_data_dir_count_with_inline_data(obj_version_id, obj_data_dir, has_inline_data) > 0 {
|
||||
if self.shared_data_dir_count_with_inline_data(obj_version_id, obj_data_dir, has_inline_data, true) > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@
|
||||
|
||||
use super::*;
|
||||
use rustfs_utils::http::{SUFFIX_TRANSITION_STATUS, get_bytes};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn physical_data_dir(version: &FileMetaShallowVersion) -> Result<Option<Uuid>> {
|
||||
if version.header.version_type != VersionType::Object {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Ok(summary) = FileMetaVersion::inspect_v2_object_slice(&version.meta) {
|
||||
return Ok(summary.physical_data_dir);
|
||||
}
|
||||
|
||||
let version = FileMetaVersion::try_from(version.meta.as_slice())?;
|
||||
let Some(obj) = version.object else {
|
||||
return Ok(None);
|
||||
@@ -43,41 +46,27 @@ fn physical_data_dir(version: &FileMetaShallowVersion) -> Result<Option<Uuid>> {
|
||||
impl FileMeta {
|
||||
pub fn find_unshared_data_dir_for_version(&self, version_id: Option<Uuid>) -> Option<Uuid> {
|
||||
let vid = version_id.unwrap_or_default();
|
||||
let mut target_data_dir = None;
|
||||
let mut target_selected = false;
|
||||
let mut other_data_dirs = HashSet::new();
|
||||
let (target_idx, target_version) = self
|
||||
.versions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, v)| v.header.version_type == VersionType::Object && v.header.version_id.unwrap_or_default() == vid)?;
|
||||
let target_data_dir = physical_data_dir(target_version).ok()??;
|
||||
|
||||
for version in self.versions.iter().filter(|v| v.header.version_type == VersionType::Object) {
|
||||
let is_target_version = version.header.version_id.unwrap_or_default() == vid;
|
||||
let dir = match physical_data_dir(version) {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
if is_target_version {
|
||||
if target_selected {
|
||||
continue;
|
||||
}
|
||||
|
||||
target_selected = true;
|
||||
target_data_dir = dir;
|
||||
if let Some(dir) = target_data_dir
|
||||
&& other_data_dirs.contains(&dir)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for (idx, version) in self.versions.iter().enumerate() {
|
||||
if idx == target_idx || version.header.version_type != VersionType::Object {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(dir) = dir {
|
||||
if target_data_dir == Some(dir) {
|
||||
match physical_data_dir(version) {
|
||||
Ok(Some(dir)) if version.header.version_id.unwrap_or_default() != vid && dir == target_data_dir => {
|
||||
return None;
|
||||
}
|
||||
other_data_dirs.insert(dir);
|
||||
Ok(_) => {}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
target_data_dir
|
||||
Some(target_data_dir)
|
||||
}
|
||||
|
||||
pub fn shard_data_dir_count(&self, vid: &Option<Uuid>, data_dir: &Option<Uuid>) -> usize {
|
||||
@@ -102,11 +91,10 @@ impl FileMeta {
|
||||
}
|
||||
|
||||
pub fn get_data_dirs(&self) -> Result<Vec<Option<Uuid>>> {
|
||||
let mut data_dirs = Vec::new();
|
||||
let mut data_dirs = Vec::with_capacity(self.versions.len());
|
||||
for version in &self.versions {
|
||||
if version.header.version_type == VersionType::Object {
|
||||
let ver = FileMetaVersion::try_from(version.meta.as_slice())?;
|
||||
data_dirs.push(ver.get_data_dir());
|
||||
data_dirs.push(FileMetaVersion::decode_data_dir_from_meta(&version.meta)?);
|
||||
}
|
||||
}
|
||||
Ok(data_dirs)
|
||||
@@ -114,7 +102,7 @@ impl FileMeta {
|
||||
|
||||
/// Count shared data directories
|
||||
pub fn shared_data_dir_count(&self, version_id: Option<Uuid>, data_dir: Option<Uuid>) -> usize {
|
||||
self.shared_data_dir_count_with_inline_data(version_id, data_dir, None)
|
||||
self.shared_data_dir_count_with_inline_data(version_id, data_dir, None, false)
|
||||
}
|
||||
|
||||
pub(super) fn shared_data_dir_count_with_inline_data(
|
||||
@@ -122,6 +110,7 @@ impl FileMeta {
|
||||
version_id: Option<Uuid>,
|
||||
data_dir: Option<Uuid>,
|
||||
has_inline_data: Option<bool>,
|
||||
stop_on_first: bool,
|
||||
) -> usize {
|
||||
let vid = version_id.unwrap_or_default();
|
||||
let Some(data_dir) = data_dir else {
|
||||
@@ -142,7 +131,12 @@ impl FileMeta {
|
||||
.filter(|v| v.header.version_type == VersionType::Object && v.header.version_id != Some(vid))
|
||||
{
|
||||
match physical_data_dir(version) {
|
||||
Ok(Some(dir)) if dir == data_dir => count += 1,
|
||||
Ok(Some(dir)) if dir == data_dir => {
|
||||
count += 1;
|
||||
if stop_on_first {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => return 1,
|
||||
}
|
||||
@@ -151,6 +145,161 @@ impl FileMeta {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct IndexedDataDir {
|
||||
data_dir: Option<Uuid>,
|
||||
physical: bool,
|
||||
}
|
||||
|
||||
impl IndexedDataDir {
|
||||
fn physical_dir(self) -> Option<Uuid> {
|
||||
self.data_dir.filter(|_| self.physical)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum IndexedDelete {
|
||||
Applied(Option<Uuid>),
|
||||
Fallback,
|
||||
}
|
||||
|
||||
/// Batch-local state: version bodies stay immutable until one final compaction.
|
||||
/// Shared-directory counts are built only when a non-inline removal needs them.
|
||||
pub(super) struct BatchDeleteIndex {
|
||||
by_version: HashMap<Uuid, usize>,
|
||||
removed: Vec<bool>,
|
||||
summaries: Vec<Option<IndexedDataDir>>,
|
||||
directory_refs: Option<HashMap<Uuid, usize>>,
|
||||
}
|
||||
|
||||
impl BatchDeleteIndex {
|
||||
pub(super) fn new(metadata: &FileMeta, requests: &[FileInfo]) -> Option<Self> {
|
||||
// Inline removals already skip sibling decodes; they need a larger
|
||||
// batch to amortize indexing over the scalar lookup/shift path.
|
||||
if requests.len() < 8 || metadata.versions.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let minimum = if metadata.data.entries().ok()? == 0 { 8 } else { 128 };
|
||||
if requests.len() < minimum
|
||||
|| metadata.versions.len() < minimum
|
||||
|| requests.iter().any(|request| {
|
||||
request.deleted
|
||||
|| request.mark_deleted
|
||||
|| request.expire_restored
|
||||
|| !request.transition_status.is_empty()
|
||||
|| !request.version_purge_status().is_empty()
|
||||
|| request.tier_free_version()
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut by_version = HashMap::with_capacity(metadata.versions.len());
|
||||
for (index, version) in metadata.versions.iter().enumerate() {
|
||||
let id = version.header.version_id?;
|
||||
if by_version.insert(id, index).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if requests.iter().any(|request| {
|
||||
by_version
|
||||
.get(&request.version_id.unwrap_or_default())
|
||||
.is_some_and(|index| metadata.versions[*index].header.version_type != VersionType::Object)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
by_version,
|
||||
removed: vec![false; metadata.versions.len()],
|
||||
summaries: vec![None; metadata.versions.len()],
|
||||
directory_refs: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn summary(&mut self, versions: &[FileMetaShallowVersion], index: usize) -> Option<IndexedDataDir> {
|
||||
if let Some(summary) = self.summaries[index] {
|
||||
return Some(summary);
|
||||
}
|
||||
let version = &versions[index];
|
||||
let summary = FileMetaVersion::inspect_v2_object_slice(&version.meta).ok()?;
|
||||
// Transition/restore state is time-sensitive and may require a free
|
||||
// version. Preserve the canonical mutation path for those records.
|
||||
if summary.transition_complete || Some(summary.version_id.unwrap_or_default()) != version.header.version_id {
|
||||
return None;
|
||||
}
|
||||
let summary = IndexedDataDir {
|
||||
data_dir: summary.data_dir,
|
||||
physical: summary.physical_data_dir.is_some(),
|
||||
};
|
||||
self.summaries[index] = Some(summary);
|
||||
Some(summary)
|
||||
}
|
||||
|
||||
fn directory_counts(&mut self, versions: &[FileMetaShallowVersion]) -> Option<HashMap<Uuid, usize>> {
|
||||
let mut counts = HashMap::with_capacity(self.by_version.len());
|
||||
for (index, version) in versions.iter().enumerate() {
|
||||
if self.removed[index] || version.header.version_type != VersionType::Object {
|
||||
continue;
|
||||
}
|
||||
if let Some(dir) = self.summary(versions, index)?.physical_dir() {
|
||||
*counts.entry(dir).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
Some(counts)
|
||||
}
|
||||
|
||||
pub(super) fn delete(
|
||||
&mut self,
|
||||
versions: &[FileMetaShallowVersion],
|
||||
request: &FileInfo,
|
||||
has_inline_data: bool,
|
||||
) -> Result<IndexedDelete> {
|
||||
request.validate_for_delete_operation()?;
|
||||
let id = request.version_id.unwrap_or_default();
|
||||
let Some(&index) = self.by_version.get(&id) else {
|
||||
return Ok(IndexedDelete::Applied(None));
|
||||
};
|
||||
let Some(summary) = self.summary(versions, index) else {
|
||||
return Ok(IndexedDelete::Fallback);
|
||||
};
|
||||
if !has_inline_data && summary.data_dir.is_some() && self.directory_refs.is_none() {
|
||||
let Some(counts) = self.directory_counts(versions) else {
|
||||
return Ok(IndexedDelete::Fallback);
|
||||
};
|
||||
self.directory_refs = Some(counts);
|
||||
}
|
||||
if let Some(counts) = self.directory_refs.as_mut()
|
||||
&& let Some(dir) = summary.physical_dir()
|
||||
{
|
||||
let count = counts.get_mut(&dir).ok_or(Error::FileCorrupt)?;
|
||||
*count = count.checked_sub(1).ok_or(Error::FileCorrupt)?;
|
||||
}
|
||||
|
||||
self.by_version.remove(&id);
|
||||
self.removed[index] = true;
|
||||
if !has_inline_data
|
||||
&& let Some(dir) = summary.data_dir
|
||||
&& self
|
||||
.directory_refs
|
||||
.as_ref()
|
||||
.and_then(|counts| counts.get(&dir))
|
||||
.is_some_and(|count| *count > 0)
|
||||
{
|
||||
return Ok(IndexedDelete::Applied(None));
|
||||
}
|
||||
Ok(IndexedDelete::Applied(summary.data_dir))
|
||||
}
|
||||
|
||||
pub(super) fn finish(self, versions: &mut Vec<FileMetaShallowVersion>) {
|
||||
let mut index = 0;
|
||||
versions.retain(|_| {
|
||||
let retain = !self.removed[index];
|
||||
index += 1;
|
||||
retain
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -337,6 +486,11 @@ mod tests {
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(decode(&actual), decode(&expected));
|
||||
assert_eq!(
|
||||
actual.versions.iter().map(|version| &version.header).collect::<Vec<_>>(),
|
||||
expected.versions.iter().map(|version| &version.header).collect::<Vec<_>>(),
|
||||
"version identities and deterministic signatures must agree"
|
||||
);
|
||||
assert_eq!(actual.data, expected.data, "retained inline bytes and ordering must be identical");
|
||||
}
|
||||
|
||||
@@ -494,4 +648,271 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_batch_fixture(inline_mode: u8, shared: bool) -> (FileMeta, Vec<FileInfo>) {
|
||||
let mut metadata = FileMeta::new();
|
||||
let mut requests = Vec::new();
|
||||
for id in 0_u128..256 {
|
||||
let version = Uuid::from_u128(id);
|
||||
let dir = if inline_mode == 1 && id == 0 {
|
||||
Uuid::from_u128(1)
|
||||
} else {
|
||||
Uuid::from_u128(10_000 + if shared { id % 7 } else { id })
|
||||
};
|
||||
let mut info = make_plain_file_info(version, dir);
|
||||
info.mod_time = Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(1_700_000_000 + i64::try_from(id).expect("id")));
|
||||
if inline_mode == 2 || (inline_mode == 1 && id % 3 != 2) {
|
||||
info.data = Some(Bytes::from_static(b"indexed payload"));
|
||||
info.set_inline_data();
|
||||
}
|
||||
metadata.add_version(info).expect("seed indexed graph");
|
||||
requests.push(FileInfo {
|
||||
version_id: Some(version),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
(metadata, requests)
|
||||
}
|
||||
|
||||
fn assert_indexed_matches_scalar(original: &FileMeta, requests: &[FileInfo]) {
|
||||
let mut scalar = original.clone();
|
||||
let expected = scalar.delete_versions_with_index(requests, None);
|
||||
let mut indexed = original.clone();
|
||||
assert_eq!(indexed.delete_versions(requests), expected);
|
||||
assert_eq!(indexed, scalar, "results, retained metadata, and inline bytes must agree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_batches_match_scalar_for_shared_inline_null_and_repeated_versions() {
|
||||
for inline_mode in 0..3 {
|
||||
for shared in [false, true] {
|
||||
let (original, requests) = indexed_batch_fixture(inline_mode, shared);
|
||||
assert!(BatchDeleteIndex::new(&original, &requests).is_some());
|
||||
let mut reverse = requests.clone();
|
||||
reverse.reverse();
|
||||
let alternating: Vec<_> = requests.iter().step_by(2).cloned().collect();
|
||||
let mut duplicate_and_missing = requests.clone();
|
||||
duplicate_and_missing.extend_from_slice(&requests[..64]);
|
||||
duplicate_and_missing.push(FileInfo {
|
||||
version_id: Some(Uuid::from_u128(999)),
|
||||
..Default::default()
|
||||
});
|
||||
for selected in [&requests, &reverse, &alternating, &duplicate_and_missing, &requests[..32]] {
|
||||
assert_indexed_matches_scalar(&original, selected);
|
||||
}
|
||||
if inline_mode > 0 {
|
||||
let mut legacy_null = original.clone();
|
||||
let payload = legacy_null
|
||||
.data
|
||||
.find(NULL_VERSION_ID)
|
||||
.expect("null lookup")
|
||||
.expect("null payload");
|
||||
legacy_null.data.remove_key(NULL_VERSION_ID).expect("remove modern null key");
|
||||
legacy_null
|
||||
.data
|
||||
.replace(&Uuid::nil().to_string(), payload)
|
||||
.expect("legacy null key");
|
||||
let mut null_request = requests.clone();
|
||||
null_request[0].version_id = None;
|
||||
assert_indexed_matches_scalar(&legacy_null, &null_request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_batches_flush_partial_removals_before_validation_or_decode_failure() {
|
||||
let (original, requests) = indexed_batch_fixture(2, false);
|
||||
let mut invalid = requests.clone();
|
||||
invalid[8].parts = vec![
|
||||
ObjectPartInfo {
|
||||
number: 1,
|
||||
..Default::default()
|
||||
};
|
||||
2
|
||||
];
|
||||
assert!(invalid[8].validate_for_delete_operation().is_err());
|
||||
assert_indexed_matches_scalar(&original, &invalid);
|
||||
|
||||
let mut corrupt = original;
|
||||
let bad = requests[8].version_id;
|
||||
corrupt
|
||||
.versions
|
||||
.iter_mut()
|
||||
.find(|version| version.header.version_id == bad)
|
||||
.expect("target")
|
||||
.meta = vec![0xc1];
|
||||
let mut index = BatchDeleteIndex::new(&corrupt, &requests).expect("header index remains eligible");
|
||||
for request in &requests[..8] {
|
||||
assert!(matches!(
|
||||
index.delete(&corrupt.versions, request, true).expect("inline delete"),
|
||||
IndexedDelete::Applied(_)
|
||||
));
|
||||
}
|
||||
assert!(index.directory_refs.is_none(), "inline removals must not decode unrelated siblings");
|
||||
assert_eq!(index.summaries.iter().flatten().count(), 8);
|
||||
assert!(matches!(
|
||||
index
|
||||
.delete(&corrupt.versions, &requests[8], true)
|
||||
.expect("fallback decision"),
|
||||
IndexedDelete::Fallback
|
||||
));
|
||||
assert_indexed_matches_scalar(&corrupt, &requests);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_batches_keep_canonical_cleanup_for_corrupt_and_transitioned_siblings() {
|
||||
let (original, requests) = indexed_batch_fixture(0, true);
|
||||
for transition in [false, true] {
|
||||
let mut metadata = original.clone();
|
||||
let sibling = metadata
|
||||
.versions
|
||||
.iter_mut()
|
||||
.find(|version| version.header.version_id == requests[255].version_id)
|
||||
.expect("sibling");
|
||||
if transition {
|
||||
let mut decoded = sibling.parse_version_meta().expect("decode sibling");
|
||||
let object = decoded.object.as_mut().expect("object");
|
||||
insert_bytes(&mut object.meta_sys, SUFFIX_TRANSITION_STATUS, TRANSITION_COMPLETE.as_bytes().to_vec());
|
||||
object.meta_user.insert(
|
||||
metadata_keys::RESTORE.to_string(),
|
||||
"ongoing-request=\"false\", expiry-date=\"9999-01-01T00:00:00Z\"".to_string(),
|
||||
);
|
||||
sibling.meta = decoded.marshal_msg().expect("encode transition");
|
||||
} else {
|
||||
sibling.meta = vec![0xc1];
|
||||
}
|
||||
let selected = &requests[..128];
|
||||
let mut index = BatchDeleteIndex::new(&metadata, selected).expect("eligible request headers");
|
||||
assert!(matches!(
|
||||
index
|
||||
.delete(&metadata.versions, &selected[0], false)
|
||||
.expect("fallback decision"),
|
||||
IndexedDelete::Fallback
|
||||
));
|
||||
assert!(!index.removed.iter().any(|removed| *removed));
|
||||
assert_indexed_matches_scalar(&metadata, selected);
|
||||
if !transition {
|
||||
let mut actual = metadata;
|
||||
assert!(
|
||||
actual
|
||||
.delete_versions(selected)
|
||||
.expect("delete valid targets")
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_indexed_reference_counts_cannot_publish_cleanup() {
|
||||
let (original, requests) = indexed_batch_fixture(0, false);
|
||||
for counts in [HashMap::new(), HashMap::from([(Uuid::from_u128(10_000), 0)])] {
|
||||
let mut metadata = original.clone();
|
||||
let mut index = BatchDeleteIndex::new(&metadata, &requests).expect("indexed graph");
|
||||
index.directory_refs = Some(counts);
|
||||
assert!(matches!(index.delete(&metadata.versions, &requests[0], false), Err(Error::FileCorrupt)));
|
||||
assert!(!index.removed.iter().any(|removed| *removed));
|
||||
index.finish(&mut metadata.versions);
|
||||
assert_eq!(metadata, original, "an inconsistent cache cannot authorize removal");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_selection_distinguishes_empty_inline_framing_from_payload_entries() {
|
||||
let (mut metadata, requests) = indexed_batch_fixture(0, false);
|
||||
metadata.data.update(&[1, 0x80]);
|
||||
assert!(BatchDeleteIndex::new(&metadata, &requests[..8]).is_some());
|
||||
assert_indexed_matches_scalar(&metadata, &requests[..8]);
|
||||
assert!(BatchDeleteIndex::new(&metadata, &requests[..1]).is_none());
|
||||
let (inline, inline_requests) = indexed_batch_fixture(2, false);
|
||||
assert!(BatchDeleteIndex::new(&inline, &inline_requests[..32]).is_none());
|
||||
assert!(BatchDeleteIndex::new(&inline, &inline_requests[..128]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_physical_batches_decode_each_version_once() {
|
||||
use super::super::version::V2_OBJECT_SCAN_COUNT;
|
||||
|
||||
let (original, requests) = indexed_batch_fixture(0, false);
|
||||
let versions = original.versions.len();
|
||||
let mut scalar = original.clone();
|
||||
V2_OBJECT_SCAN_COUNT.with(|count| count.set(0));
|
||||
let expected = scalar.delete_versions_with_index(&requests, None).expect("scalar batch");
|
||||
let scalar_scans = V2_OBJECT_SCAN_COUNT.with(std::cell::Cell::get);
|
||||
let mut indexed = original;
|
||||
V2_OBJECT_SCAN_COUNT.with(|count| count.set(0));
|
||||
let actual = indexed.delete_versions(&requests).expect("indexed batch");
|
||||
let indexed_scans = V2_OBJECT_SCAN_COUNT.with(std::cell::Cell::get);
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(indexed, scalar);
|
||||
assert_eq!(scalar_scans, versions * (versions + 1) / 2);
|
||||
assert_eq!(indexed_scans, versions, "a batch must not re-decode retained siblings");
|
||||
println!("V2 scans for {versions} versions: scalar={scalar_scans}, indexed={indexed_scans}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_batches_decline_ambiguous_headers_and_nonordinary_requests() {
|
||||
let (original, requests) = indexed_batch_fixture(0, false);
|
||||
let mut duplicate = original.clone();
|
||||
duplicate.versions[0].header.version_id = duplicate.versions[1].header.version_id;
|
||||
assert!(BatchDeleteIndex::new(&duplicate, &requests).is_none());
|
||||
assert_indexed_matches_scalar(&duplicate, &requests);
|
||||
let mut absent_id = original.clone();
|
||||
absent_id.versions[0].header.version_id = None;
|
||||
assert!(BatchDeleteIndex::new(&absent_id, &requests).is_none());
|
||||
assert_indexed_matches_scalar(&absent_id, &requests);
|
||||
let mut marker = requests[0].clone();
|
||||
marker.deleted = true;
|
||||
marker.mod_time = Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(1_700_000_000));
|
||||
let mut retained = requests[0].clone();
|
||||
retained.mark_deleted = true;
|
||||
let mut restored = requests[0].clone();
|
||||
restored.expire_restored = true;
|
||||
let mut transition = requests[0].clone();
|
||||
transition.transition_status = TRANSITION_COMPLETE.to_string();
|
||||
let mut free_version = requests[0].clone();
|
||||
free_version.set_tier_free_version();
|
||||
let mut pending = requests[0].clone();
|
||||
pending.replication_state_internal = Some(ReplicationState {
|
||||
version_purge_status_internal: Some("target=PENDING;".into()),
|
||||
purge_targets: version_purge_statuses_map("target=PENDING;"),
|
||||
..Default::default()
|
||||
});
|
||||
for operation in [marker, retained, restored, transition, free_version, pending] {
|
||||
let mut complex = requests.clone();
|
||||
complex[0] = operation;
|
||||
assert!(BatchDeleteIndex::new(&original, &complex).is_none());
|
||||
// State changes re-encode unordered maps; their semantic and
|
||||
// signature contract is covered by the canonical sequential oracle.
|
||||
assert_batch_matches_sequential(&original, &complex);
|
||||
}
|
||||
|
||||
let mut corrupt_inline = original;
|
||||
corrupt_inline.data.update(&[1, 0x81, 0xa1, b'x', 0xc4, 255]);
|
||||
let before = corrupt_inline.clone();
|
||||
assert!(corrupt_inline.delete_versions(&requests).is_err());
|
||||
assert_eq!(corrupt_inline, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_unshared_data_dir_and_shared_count_fail_closed_on_corrupt_sibling() {
|
||||
let target_version = Uuid::new_v4();
|
||||
let target_data_dir = Uuid::new_v4();
|
||||
let mut meta = FileMeta::new();
|
||||
meta.add_version(make_plain_file_info(target_version, target_data_dir))
|
||||
.expect("seed target version");
|
||||
meta.versions.push(FileMetaShallowVersion {
|
||||
header: FileMetaVersionHeader {
|
||||
version_id: Some(Uuid::new_v4()),
|
||||
version_type: VersionType::Object,
|
||||
..Default::default()
|
||||
},
|
||||
meta: vec![0x81, 0xa4, b'V', b'2', b'O', b'b'],
|
||||
});
|
||||
|
||||
assert_eq!(meta.find_unshared_data_dir_for_version(Some(target_version)), None);
|
||||
assert_eq!(meta.shared_data_dir_count(Some(target_version), Some(target_data_dir)), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +122,14 @@ pub(crate) fn read_slice<'a>(cur: &mut std::io::Cursor<&'a [u8]>, len: usize) ->
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn read_str_slice<'a>(cur: &mut std::io::Cursor<&'a [u8]>) -> Result<&'a str> {
|
||||
pub(crate) fn read_str_bytes_slice<'a>(cur: &mut std::io::Cursor<&'a [u8]>) -> Result<&'a [u8]> {
|
||||
let len = rmp::decode::read_str_len(cur)? as usize;
|
||||
let bytes = read_slice(cur, len)?;
|
||||
read_slice(cur, len)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn read_str_slice<'a>(cur: &mut std::io::Cursor<&'a [u8]>) -> Result<&'a str> {
|
||||
let bytes = read_str_bytes_slice(cur)?;
|
||||
std::str::from_utf8(bytes).map_err(|e| Error::FromUtf8(e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -911,18 +911,30 @@ impl<W: AsyncWrite + Unpin> MetacacheWriter<W> {
|
||||
return Err(Error::other("metacacheWriter: no name"));
|
||||
}
|
||||
|
||||
self.write_obj(obj).await?;
|
||||
Self::encode_obj(&mut self.buf, obj)?;
|
||||
if self.buf.len() >= METACACHE_READ_CHUNK_SIZE {
|
||||
self.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.buf.is_empty() {
|
||||
self.flush().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_obj(buf: &mut Vec<u8>, obj: &MetaCacheEntry) -> Result<()> {
|
||||
rmp::encode::write_bool(buf, true).map_err(|e| Error::other(format!("{e:?}")))?;
|
||||
rmp::encode::write_str(buf, &obj.name).map_err(|e| Error::other(format!("{e:?}")))?;
|
||||
rmp::encode::write_bin(buf, &obj.metadata).map_err(|e| Error::other(format!("{e:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn write_obj(&mut self, obj: &MetaCacheEntry) -> Result<()> {
|
||||
self.init().await?;
|
||||
|
||||
rmp::encode::write_bool(&mut self.buf, true).map_err(|e| Error::other(format!("{e:?}")))?;
|
||||
rmp::encode::write_str(&mut self.buf, &obj.name).map_err(|e| Error::other(format!("{e:?}")))?;
|
||||
rmp::encode::write_bin(&mut self.buf, &obj.metadata).map_err(|e| Error::other(format!("{e:?}")))?;
|
||||
Self::encode_obj(&mut self.buf, obj)?;
|
||||
self.flush().await?;
|
||||
|
||||
Ok(())
|
||||
@@ -942,6 +954,7 @@ pub struct MetacacheReader<R> {
|
||||
in_entry: bool,
|
||||
err: Option<Error>,
|
||||
buf: Vec<u8>,
|
||||
pos: usize,
|
||||
has_current: bool,
|
||||
}
|
||||
|
||||
@@ -953,63 +966,111 @@ impl<R: AsyncRead + Unpin> MetacacheReader<R> {
|
||||
in_entry: false,
|
||||
err: None,
|
||||
buf: Vec::new(),
|
||||
pos: 0,
|
||||
has_current: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_more(&mut self, read_size: usize) -> Result<&[u8]> {
|
||||
self.read_more_inner(read_size, true).await
|
||||
}
|
||||
|
||||
async fn read_more_inner(&mut self, read_size: usize, lookahead: bool) -> Result<&[u8]> {
|
||||
if let Some(err) = &self.err {
|
||||
return Err(err.clone());
|
||||
}
|
||||
|
||||
let pref = self.buf.len();
|
||||
let Some(ext_size) = pref.checked_add(read_size) else {
|
||||
let err = Error::other("metacache stream: element length overflow");
|
||||
self.err = Some(err.clone());
|
||||
return Err(err);
|
||||
};
|
||||
if self.buf.len() - self.pos >= read_size {
|
||||
let start = self.pos;
|
||||
self.pos += read_size;
|
||||
return Ok(&self.buf[start..self.pos]);
|
||||
}
|
||||
|
||||
if self.pos > 0 {
|
||||
self.buf.drain(..self.pos);
|
||||
self.pos = 0;
|
||||
}
|
||||
|
||||
// A whole xl.meta can legitimately exceed the limit on its individual
|
||||
// msgpack values. Grow only as payload arrives, so a corrupt length
|
||||
// prefix cannot cause a speculative allocation of the advertised size.
|
||||
while self.buf.len() < ext_size {
|
||||
while self.buf.len() < read_size {
|
||||
let start = self.buf.len();
|
||||
let extra = (ext_size - start).min(METACACHE_READ_CHUNK_SIZE);
|
||||
let extra = if lookahead {
|
||||
let min_chunk = METACACHE_READ_CHUNK_SIZE.saturating_sub(start);
|
||||
(read_size - start).clamp(min_chunk, METACACHE_READ_CHUNK_SIZE)
|
||||
} else {
|
||||
(read_size - start).min(METACACHE_READ_CHUNK_SIZE)
|
||||
};
|
||||
if let Err(e) = self.buf.try_reserve(extra) {
|
||||
let err = Error::other(format!("metacache stream: buffer allocation of {extra} bytes failed: {e}"));
|
||||
self.err = Some(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
self.buf.resize(start + extra, 0);
|
||||
if let Err(e) = self.rd.read_exact(&mut self.buf[start..]).await {
|
||||
// Listings treat Unexpected/UnexpectedEof as clean exhaustion.
|
||||
// An incomplete entry must not hide remaining keys. Preserve
|
||||
// the existing empty/between-entry EOF handling.
|
||||
let err = match e.kind() {
|
||||
std::io::ErrorKind::UnexpectedEof if self.in_entry => Error::FileCorrupt,
|
||||
_ => Error::from(e),
|
||||
};
|
||||
self.err = Some(err.clone());
|
||||
return Err(err);
|
||||
let mut dst = bytes::BufMut::limit(&mut self.buf, extra);
|
||||
match self.rd.read_buf(&mut dst).await {
|
||||
Ok(0) => {
|
||||
// Listings treat Unexpected/UnexpectedEof as clean exhaustion.
|
||||
// An incomplete entry must not hide remaining keys. Preserve
|
||||
// the existing empty/between-entry EOF handling.
|
||||
let err = if self.in_entry {
|
||||
Error::FileCorrupt
|
||||
} else {
|
||||
Error::Unexpected
|
||||
};
|
||||
self.err = Some(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let err = match e.kind() {
|
||||
std::io::ErrorKind::UnexpectedEof if self.in_entry => Error::FileCorrupt,
|
||||
_ => Error::from(e),
|
||||
};
|
||||
self.err = Some(err.clone());
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(&self.buf[pref..ext_size])
|
||||
self.pos = read_size;
|
||||
Ok(&self.buf[..read_size])
|
||||
}
|
||||
|
||||
async fn discard(&mut self, mut size: usize) -> Result<()> {
|
||||
self.reset();
|
||||
let avail = self.buf.len() - self.pos;
|
||||
if size <= avail {
|
||||
self.pos += size;
|
||||
self.reset();
|
||||
return Ok(());
|
||||
}
|
||||
size -= avail;
|
||||
self.buf.clear();
|
||||
self.pos = 0;
|
||||
while size > 0 {
|
||||
let chunk = size.min(METACACHE_READ_CHUNK_SIZE);
|
||||
self.read_more(chunk).await?;
|
||||
self.read_more_inner(chunk, false).await?;
|
||||
size -= chunk;
|
||||
self.reset();
|
||||
self.buf.clear();
|
||||
self.pos = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.buf.clear();
|
||||
if self.pos >= self.buf.len() {
|
||||
self.buf.clear();
|
||||
self.pos = 0;
|
||||
if self.buf.capacity() > METACACHE_READ_CHUNK_SIZE {
|
||||
self.buf.shrink_to(METACACHE_READ_CHUNK_SIZE);
|
||||
}
|
||||
} else if self.pos >= METACACHE_READ_CHUNK_SIZE {
|
||||
self.buf.drain(..self.pos);
|
||||
self.pos = 0;
|
||||
if self.buf.capacity() > METACACHE_READ_CHUNK_SIZE {
|
||||
self.buf.shrink_to(METACACHE_READ_CHUNK_SIZE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_init(&mut self) -> Result<()> {
|
||||
@@ -1164,9 +1225,11 @@ impl<R: AsyncRead + Unpin> MetacacheReader<R> {
|
||||
|
||||
let l = self.read_bin_len().await?;
|
||||
|
||||
let buf = self.read_more(l as usize).await?;
|
||||
|
||||
let metadata = buf.to_vec();
|
||||
let metadata = if l == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.read_more(l as usize).await?.to_vec()
|
||||
};
|
||||
|
||||
self.in_entry = false;
|
||||
self.reset();
|
||||
@@ -1369,6 +1432,74 @@ mod tests {
|
||||
assert_eq!(reader.read_all().await.expect("remaining entries must decode"), entries[3..]);
|
||||
}
|
||||
|
||||
struct FragmentedReader<'a> {
|
||||
data: &'a [u8],
|
||||
chunk: usize,
|
||||
reads: usize,
|
||||
}
|
||||
|
||||
impl AsyncRead for FragmentedReader<'_> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
let count = this.data.len().min(this.chunk).min(buf.remaining());
|
||||
buf.put_slice(&this.data[..count]);
|
||||
this.data = &this.data[count..];
|
||||
this.reads += 1;
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_buffered_wire_frames_survive_fragmented_reads_and_skips() {
|
||||
for version in [1, 2] {
|
||||
let mut bytes = vec![0xcc, version];
|
||||
let payloads = [
|
||||
vec![1; METACACHE_READ_CHUNK_SIZE - 1],
|
||||
vec![],
|
||||
vec![3; METACACHE_READ_CHUNK_SIZE + 1],
|
||||
];
|
||||
for (i, payload) in payloads.iter().enumerate() {
|
||||
rmp::encode::write_bool(&mut bytes, true).expect("entry marker");
|
||||
rmp::encode::write_str(&mut bytes, &format!("key-{i}")).expect("entry name");
|
||||
rmp::encode::write_bin(&mut bytes, payload).expect("entry metadata");
|
||||
}
|
||||
rmp::encode::write_bool(&mut bytes, false).expect("stream end");
|
||||
for chunk in [1, 2, 7, 4096, METACACHE_READ_CHUNK_SIZE] {
|
||||
let mut reader = MetacacheReader::new(FragmentedReader {
|
||||
data: &bytes,
|
||||
chunk,
|
||||
reads: 0,
|
||||
});
|
||||
assert_eq!(reader.peek().await.expect("first entry").expect("present").metadata, payloads[0]);
|
||||
reader.skip(2).await.expect("consume first and empty second entries");
|
||||
let last = reader.peek().await.expect("last entry").expect("present");
|
||||
assert_eq!(last.name, "key-2");
|
||||
assert_eq!(last.metadata, payloads[2]);
|
||||
assert!(reader.peek().await.expect("valid terminator").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_coalesces_small_entries_without_per_field_reads() {
|
||||
let mut bytes = vec![0xcc, 2];
|
||||
for _ in 0..1000 {
|
||||
bytes.extend_from_slice(&[0xc3, 0xa1, b'x', 0xc4, 1, 0x42]);
|
||||
}
|
||||
bytes.push(0xc2);
|
||||
let mut reader = MetacacheReader::new(FragmentedReader {
|
||||
data: &bytes,
|
||||
chunk: METACACHE_READ_CHUNK_SIZE,
|
||||
reads: 0,
|
||||
});
|
||||
assert_eq!(reader.read_all().await.expect("decode page").len(), 1000);
|
||||
assert_eq!(reader.rd.reads, 1, "a ready page smaller than 64 KiB needs one stream read");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reader_invalid_utf8_name_remains_a_sticky_error() {
|
||||
let stream = vec![0xcc, 2, 0xc3, 0xa1, 0xff, 0xc4, 0, 0xc2];
|
||||
|
||||
@@ -1388,15 +1388,21 @@ impl MrfRuntime {
|
||||
}
|
||||
|
||||
fn discharge_durable_replay_anchors(&mut self) {
|
||||
if self.durable_replay_anchors.is_empty() && self.partial_writes.anchors().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if !rustfs_common::mrf_channel::mrf_durable_repair_events_may_be_pending() {
|
||||
// Keep the nonempty-anchor publication. An empty observation
|
||||
// acknowledges nothing; concurrent events can wait or be evicted.
|
||||
self.publish_metrics();
|
||||
return;
|
||||
}
|
||||
let mut anchors: Vec<_> = self
|
||||
.durable_replay_anchors
|
||||
.iter()
|
||||
.chain(self.partial_writes.anchors())
|
||||
.cloned()
|
||||
.collect();
|
||||
if anchors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut buckets: Vec<Arc<str>> = anchors.iter().map(|anchor| anchor.bucket.clone()).collect();
|
||||
buckets.sort_unstable();
|
||||
buckets.dedup();
|
||||
@@ -2057,6 +2063,8 @@ mod tests {
|
||||
use std::sync::Arc as StdArc;
|
||||
use std::time::{Duration as StdDuration, Instant};
|
||||
|
||||
mod empty_events_tests;
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn journal_disk_io_bounds_fanout_and_collects_every_result() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
@@ -363,7 +363,7 @@ impl PartialWrites {
|
||||
}
|
||||
|
||||
pub(super) async fn note_retired_generation_proofs(&mut self, manager: &HealManager) {
|
||||
let mut anchors_by_generation: HashMap<(Arc<str>, Uuid), Vec<MrfDurableRepairAnchor>> = HashMap::new();
|
||||
let mut intents_by_generation: HashMap<(Arc<str>, Uuid), Vec<&MrfIntent>> = HashMap::new();
|
||||
for entry in self.entries.values() {
|
||||
if !entry.persisted {
|
||||
continue;
|
||||
@@ -371,21 +371,19 @@ impl PartialWrites {
|
||||
let Some(source_incarnation) = entry.source_bucket_incarnation_id else {
|
||||
continue;
|
||||
};
|
||||
let Some(anchor) = MrfDurableRepairAnchor::from_intent(&entry.intent, source_incarnation)
|
||||
.filter(|anchor| anchor.bucket_incarnation_id == source_incarnation)
|
||||
else {
|
||||
if !MrfDurableRepairAnchor::is_eligible_for_source(&entry.intent, source_incarnation) {
|
||||
continue;
|
||||
};
|
||||
anchors_by_generation
|
||||
.entry((anchor.bucket.clone(), source_incarnation))
|
||||
}
|
||||
intents_by_generation
|
||||
.entry((entry.intent.bucket.clone(), source_incarnation))
|
||||
.or_default()
|
||||
.push(anchor);
|
||||
.push(&entry.intent);
|
||||
}
|
||||
|
||||
self.retirement_checks
|
||||
.retain(|generation, _| anchors_by_generation.contains_key(generation));
|
||||
.retain(|generation, _| intents_by_generation.contains_key(generation));
|
||||
let now = Instant::now();
|
||||
for ((bucket, incarnation), anchors) in anchors_by_generation {
|
||||
for ((bucket, incarnation), intents) in intents_by_generation {
|
||||
let retired = match self.retirement_checks.get(&(bucket.clone(), incarnation)) {
|
||||
Some((_, true)) => true,
|
||||
Some((checked_at, false))
|
||||
@@ -406,11 +404,16 @@ impl PartialWrites {
|
||||
continue;
|
||||
}
|
||||
|
||||
for anchor in anchors {
|
||||
for intent in intents {
|
||||
let Some(anchor) = MrfDurableRepairAnchor::from_intent(intent, incarnation)
|
||||
.filter(|anchor| anchor.bucket_incarnation_id == incarnation)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
rustfs_common::mrf_channel::note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
bucket: anchor.bucket,
|
||||
object: anchor.object,
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: anchor.delete_marker_purge,
|
||||
@@ -1017,6 +1020,8 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod retirement_tests;
|
||||
|
||||
fn intent(object: &str) -> MrfIntent {
|
||||
let mut intent = MrfIntent {
|
||||
bucket: Arc::from("partial-write-retention"),
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::heal::DiskStore;
|
||||
use crate::heal::storage::{HealListItem, HealObjectInfo};
|
||||
use crate::heal::storage_api::storage::{BucketInfo, DiskSetSelector};
|
||||
use crate::{Error, Result};
|
||||
use rustfs_common::mrf_channel::{MrfDeleteMarkerPurge, take_mrf_verified_repair_events_for};
|
||||
use rustfs_heal_contracts::heal_channel::HealOpts;
|
||||
use rustfs_madmin::heal_commands::HealResultItem;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct RetirementCall {
|
||||
bucket: String,
|
||||
incarnation: Uuid,
|
||||
at: Instant,
|
||||
object_refs: Vec<usize>,
|
||||
}
|
||||
|
||||
// Controlled callback provider for producer cost/cache behavior. The real
|
||||
// ECStore retirement-authority regression remains a separate required gate.
|
||||
struct RetirementStorage {
|
||||
watched: Vec<Arc<str>>,
|
||||
calls: Mutex<Vec<RetirementCall>>,
|
||||
result: AtomicU8,
|
||||
delay: Duration,
|
||||
}
|
||||
|
||||
impl RetirementStorage {
|
||||
fn new(watched: Vec<Arc<str>>, delay: Duration) -> Self {
|
||||
Self {
|
||||
watched,
|
||||
calls: Mutex::new(Vec::new()),
|
||||
result: AtomicU8::new(0),
|
||||
delay,
|
||||
}
|
||||
}
|
||||
fn calls(&self) -> Vec<RetirementCall> {
|
||||
self.calls.lock().expect("retirement calls").clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl HealStorageAPI for RetirementStorage {
|
||||
async fn mrf_bucket_incarnation_retired(&self, bucket: &str, expected: Uuid) -> Result<bool> {
|
||||
self.calls.lock().expect("retirement calls").push(RetirementCall {
|
||||
bucket: bucket.to_string(),
|
||||
incarnation: expected,
|
||||
at: Instant::now(),
|
||||
object_refs: self.watched.iter().map(Arc::strong_count).collect(),
|
||||
});
|
||||
if !self.delay.is_zero() {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
}
|
||||
match self.result.load(Ordering::Relaxed) {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(Error::other("retirement storage unavailable")),
|
||||
}
|
||||
}
|
||||
async fn get_object_meta(&self, _: &str, _: &str) -> Result<Option<HealObjectInfo>> {
|
||||
panic!("retirement must not read objects")
|
||||
}
|
||||
async fn ec_decode_rebuild(&self, _: &str, _: &str) -> Result<Vec<u8>> {
|
||||
panic!("retirement must not rebuild objects")
|
||||
}
|
||||
async fn get_bucket_info(&self, _: &str) -> Result<Option<BucketInfo>> {
|
||||
panic!("retirement uses exact generation checks")
|
||||
}
|
||||
async fn list_buckets(&self) -> Result<Vec<BucketInfo>> {
|
||||
panic!("retirement must not list buckets")
|
||||
}
|
||||
async fn list_buckets_for_admin_heal(&self, _: Option<DiskSetSelector>) -> Result<Vec<BucketInfo>> {
|
||||
panic!("retirement must not start admin healing")
|
||||
}
|
||||
async fn list_objects_for_heal_page(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: bool,
|
||||
) -> Result<(Vec<HealListItem>, Option<String>, bool)> {
|
||||
panic!("retirement must not enumerate objects")
|
||||
}
|
||||
async fn object_exists(&self, _: &str, _: &str) -> Result<bool> {
|
||||
panic!("retirement must not probe objects")
|
||||
}
|
||||
async fn heal_object(&self, _: &str, _: &str, _: Option<&str>, _: &HealOpts) -> Result<(HealResultItem, Option<Error>)> {
|
||||
panic!("retirement must not heal objects")
|
||||
}
|
||||
async fn heal_bucket(&self, _: &str, _: &HealOpts) -> Result<HealResultItem> {
|
||||
panic!("retirement must not heal buckets")
|
||||
}
|
||||
async fn heal_format(&self, _: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||
panic!("retirement must not heal formats")
|
||||
}
|
||||
async fn get_disk_for_resume(&self, _: &str) -> Result<DiskStore> {
|
||||
panic!("retirement must not open resume disks")
|
||||
}
|
||||
}
|
||||
|
||||
fn admit(writes: &mut PartialWrites, bucket: &str, object: &str, source: Option<Uuid>, persisted: bool) -> PartialWriteKey {
|
||||
let item = MrfIntent {
|
||||
bucket: Arc::from(bucket),
|
||||
object: Arc::from(object),
|
||||
version_id: None,
|
||||
kind: MrfKind::PartialWrite,
|
||||
delete_marker_purge: None,
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
}),
|
||||
lease: None,
|
||||
enqueued_at_ms: 17,
|
||||
attempts: 0,
|
||||
};
|
||||
let key = PartialWriteKey::new(&item, source);
|
||||
writes
|
||||
.admit_with_source_incarnation(item, source, 256, usize::MAX)
|
||||
.expect("admit retirement fixture");
|
||||
writes.entries.get_mut(&key).expect("retained fixture").persisted = persisted;
|
||||
key
|
||||
}
|
||||
|
||||
fn expected_event(entry: &Responsibility, source: Uuid) -> MrfVerifiedRepairEvent {
|
||||
let anchor = MrfDurableRepairAnchor::from_intent(&entry.intent, source).expect("exact eligible source");
|
||||
MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket,
|
||||
object: anchor.object,
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: anchor.delete_marker_purge,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::AuthoritativelyAbsent,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retirement_avoids_prepared_object_handles_and_preserves_false_error_true_cache() {
|
||||
let bucket = format!("retirement-cost-{}", Uuid::new_v4());
|
||||
let source = Uuid::new_v4();
|
||||
let mut writes = PartialWrites::default();
|
||||
for index in 0..32 {
|
||||
admit(&mut writes, &bucket, &format!("object-{index}"), Some(source), true);
|
||||
}
|
||||
let mut purge = intent("purge-proof");
|
||||
purge.bucket = Arc::from(bucket.as_str());
|
||||
purge.kind = MrfKind::DeleteMarkerPurge;
|
||||
purge.version_id = Some([7; 16]);
|
||||
purge.scope = Some(MrfScope {
|
||||
pool_index: 7,
|
||||
set_index: 12,
|
||||
});
|
||||
purge.delete_marker_purge = MrfDeleteMarkerPurge::new(source, source, [9; 32], vec![7]);
|
||||
writes
|
||||
.admit_with_source_incarnation(purge, Some(source), 256, usize::MAX)
|
||||
.expect("admit exact marker purge");
|
||||
writes.mark_persisted();
|
||||
assert!(
|
||||
writes.entries.values().all(|entry| entry.anchor.is_none()),
|
||||
"undispatched obligations still require retirement checks"
|
||||
);
|
||||
let storage = Arc::new(RetirementStorage::new(
|
||||
writes.entries.values().map(|entry| entry.intent.object.clone()).collect(),
|
||||
Duration::ZERO,
|
||||
));
|
||||
let baseline = storage.watched.iter().map(Arc::strong_count).collect::<Vec<_>>();
|
||||
let manager = HealManager::new_without_root_recovery_for_test(storage.clone(), None);
|
||||
let original = writes.checkpoint_records(super::super::super::intent_digest);
|
||||
let started = Instant::now();
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 1);
|
||||
assert_eq!(
|
||||
storage.calls()[0].object_refs,
|
||||
baseline,
|
||||
"the actual retirement storage callback must not see N eagerly owned anchor object handles"
|
||||
);
|
||||
assert!(take_mrf_verified_repair_events_for(&bucket).is_empty());
|
||||
assert_eq!(writes.retirement_checks[&(Arc::from(bucket.as_str()), source)], (started, false));
|
||||
tokio::time::advance(Duration::from_millis(4999)).await;
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 1, "cached false does not query or refresh its timestamp");
|
||||
storage.result.store(2, Ordering::Relaxed);
|
||||
tokio::time::advance(Duration::from_millis(1)).await;
|
||||
let failed_at = Instant::now();
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 2, "the exact TTL boundary must recheck");
|
||||
assert_eq!(
|
||||
writes.retirement_checks[&(Arc::from(bucket.as_str()), source)],
|
||||
(failed_at, false),
|
||||
"I/O error is cached false"
|
||||
);
|
||||
tokio::time::advance(Duration::from_millis(4999)).await;
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 2);
|
||||
storage.result.store(1, Ordering::Relaxed);
|
||||
tokio::time::advance(Duration::from_millis(1)).await;
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 3);
|
||||
assert!(storage.calls().iter().all(|call| call.object_refs == baseline));
|
||||
let expected = writes
|
||||
.entries
|
||||
.values()
|
||||
.map(|entry| expected_event(entry, source))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
take_mrf_verified_repair_events_for(&bucket),
|
||||
expected,
|
||||
"proofs preserve exact fields and entry order"
|
||||
);
|
||||
tokio::time::advance(Duration::from_secs(100)).await;
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 3, "true is sticky while the eligible generation remains");
|
||||
assert_eq!(
|
||||
take_mrf_verified_repair_events_for(&bucket),
|
||||
expected,
|
||||
"cached true still republishes the original exact proofs"
|
||||
);
|
||||
assert_eq!(
|
||||
writes.checkpoint_records(super::super::super::intent_digest),
|
||||
original,
|
||||
"proof production does not alter responsibility state"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retirement_keeps_one_preawait_now_and_one_query_per_generation() {
|
||||
let bucket = format!("retirement-clock-{}", Uuid::new_v4());
|
||||
let sources = [Uuid::new_v4(), Uuid::new_v4()];
|
||||
let mut writes = PartialWrites::default();
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
admit(&mut writes, &bucket, &format!("object-{index}-a"), Some(*source), true);
|
||||
admit(&mut writes, &bucket, &format!("object-{index}-b"), Some(*source), true);
|
||||
}
|
||||
let storage = Arc::new(RetirementStorage::new(Vec::new(), Duration::from_secs(3)));
|
||||
let manager = HealManager::new_without_root_recovery_for_test(storage.clone(), None);
|
||||
let started = Instant::now();
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 2);
|
||||
assert!(
|
||||
writes.retirement_checks.values().all(|value| *value == (started, false)),
|
||||
"every group stores the single time captured before either await"
|
||||
);
|
||||
assert_eq!(
|
||||
storage
|
||||
.calls()
|
||||
.iter()
|
||||
.map(|call| (&call.bucket, call.incarnation))
|
||||
.collect::<HashSet<_>>()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
storage.calls()[0].at < storage.calls()[1].at,
|
||||
"the callback really spans different instants"
|
||||
);
|
||||
let next = Instant::now();
|
||||
assert!(next.duration_since(started) >= Duration::from_secs(6));
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 4, "both false groups expire from the same pre-await timestamp");
|
||||
assert!(writes.retirement_checks.values().all(|value| *value == (next, false)));
|
||||
assert!(take_mrf_verified_repair_events_for(&bucket).is_empty());
|
||||
storage.result.store(1, Ordering::Relaxed);
|
||||
// The preceding callbacks took six seconds, so both false entries expire.
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 6);
|
||||
let events = take_mrf_verified_repair_events_for(&bucket);
|
||||
assert_eq!(events.len(), 4);
|
||||
assert_ne!(events[0].bucket_incarnation_id, events[2].bucket_incarnation_id);
|
||||
assert_eq!(events[0].bucket_incarnation_id, events[1].bucket_incarnation_id);
|
||||
assert_eq!(events[2].bucket_incarnation_id, events[3].bucket_incarnation_id);
|
||||
for source in sources {
|
||||
let actual = events
|
||||
.iter()
|
||||
.filter(|event| event.bucket_incarnation_id == source)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let expected = writes
|
||||
.entries
|
||||
.values()
|
||||
.filter(|entry| entry.source_bucket_incarnation_id == Some(source))
|
||||
.map(|entry| expected_event(entry, source))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"each original generation group remains contiguous and keeps its entry order"
|
||||
);
|
||||
assert_eq!(storage.calls().iter().filter(|call| call.incarnation == source).count(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retirement_cache_membership_uses_only_exact_eligible_persisted_sources() {
|
||||
let bucket = format!("retirement-membership-{}", Uuid::new_v4());
|
||||
let valid = Uuid::new_v4();
|
||||
let unpersisted = Uuid::new_v4();
|
||||
let unleased = Uuid::new_v4();
|
||||
let mismatched = Uuid::new_v4();
|
||||
let purge_source = Uuid::new_v4();
|
||||
let mut writes = PartialWrites::default();
|
||||
let valid_key = admit(&mut writes, &bucket, "valid", Some(valid), true);
|
||||
let unknown_key = admit(&mut writes, &bucket, "unknown", None, true);
|
||||
let unpersisted_key = admit(&mut writes, &bucket, "not-persisted", Some(unpersisted), false);
|
||||
let unleased_key = admit(&mut writes, &bucket, "no-lease", Some(unleased), true);
|
||||
writes.entries.get_mut(&unleased_key).expect("lease fixture").intent.lease = None;
|
||||
let mut purge = intent("mismatched-purge");
|
||||
purge.bucket = Arc::from(bucket.as_str());
|
||||
purge.kind = MrfKind::DeleteMarkerPurge;
|
||||
purge.version_id = Some([7; 16]);
|
||||
purge.delete_marker_purge = MrfDeleteMarkerPurge::new(purge_source, purge_source, [9; 32], vec![7]);
|
||||
writes
|
||||
.admit_with_source_incarnation(purge, Some(mismatched), 256, usize::MAX)
|
||||
.expect("retain a mismatched source without rewriting it");
|
||||
writes.mark_persisted();
|
||||
writes
|
||||
.entries
|
||||
.get_mut(&unpersisted_key)
|
||||
.expect("unpersisted fixture")
|
||||
.persisted = false;
|
||||
let original_unknown = writes.entries.get(&unknown_key).expect("old None").responsibility_id;
|
||||
let unknown_before = writes
|
||||
.checkpoint_records(super::super::super::intent_digest)
|
||||
.into_iter()
|
||||
.find(|record| record.responsibility_id == original_unknown)
|
||||
.expect("original None checkpoint");
|
||||
let now = Instant::now();
|
||||
for source in [valid, unpersisted, unleased, mismatched, purge_source] {
|
||||
writes
|
||||
.retirement_checks
|
||||
.insert((Arc::from(bucket.as_str()), source), (now, true));
|
||||
}
|
||||
let storage = Arc::new(RetirementStorage::new(Vec::new(), Duration::ZERO));
|
||||
let manager = HealManager::new_without_root_recovery_for_test(storage.clone(), None);
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(writes.retirement_checks.len(), 1);
|
||||
assert!(writes.retirement_checks.contains_key(&(Arc::from(bucket.as_str()), valid)));
|
||||
assert!(storage.calls().is_empty(), "the only eligible generation uses its sticky true cache");
|
||||
assert_eq!(
|
||||
take_mrf_verified_repair_events_for(&bucket),
|
||||
vec![expected_event(&writes.entries[&valid_key], valid)]
|
||||
);
|
||||
writes.entries.remove(&valid_key);
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert!(
|
||||
writes.retirement_checks.is_empty(),
|
||||
"None and ineligible entries cannot keep an old cache generation alive"
|
||||
);
|
||||
writes
|
||||
.entries
|
||||
.get_mut(&unpersisted_key)
|
||||
.expect("activate persisted source")
|
||||
.persisted = true;
|
||||
writes.note_retired_generation_proofs(&manager).await;
|
||||
assert_eq!(storage.calls().len(), 1, "a newly eligible source needs a real retirement lookup");
|
||||
assert_eq!(storage.calls()[0].incarnation, unpersisted);
|
||||
assert_eq!(
|
||||
writes
|
||||
.checkpoint_records(super::super::super::intent_digest)
|
||||
.into_iter()
|
||||
.find(|record| record.responsibility_id == original_unknown),
|
||||
Some(unknown_before)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retirement_cancellation_cannot_publish_an_unfinished_retirement() {
|
||||
let bucket = format!("retirement-cancel-{}", Uuid::new_v4());
|
||||
let mut writes = PartialWrites::default();
|
||||
admit(&mut writes, &bucket, "object", Some(Uuid::new_v4()), true);
|
||||
let original = writes.checkpoint_records(super::super::super::intent_digest);
|
||||
let storage = Arc::new(RetirementStorage::new(Vec::new(), Duration::from_secs(3)));
|
||||
storage.result.store(1, Ordering::Relaxed);
|
||||
let manager = HealManager::new_without_root_recovery_for_test(storage.clone(), None);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(1), writes.note_retired_generation_proofs(&manager))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(storage.calls().len(), 1);
|
||||
assert!(writes.retirement_checks.is_empty());
|
||||
assert!(take_mrf_verified_repair_events_for(&bucket).is_empty());
|
||||
assert_eq!(writes.checkpoint_records(super::super::super::intent_digest), original);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use rustfs_common::mrf_channel::{
|
||||
MrfDurableRepairAnchor, MrfScope, MrfUnverifiedLegacyEvent, mrf_durable_repair_events_may_be_pending,
|
||||
note_mrf_unverified_legacy, note_mrf_verified_repair,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn isolated(name: &str) -> bool {
|
||||
const CHILD: &str = "RUSTFS_MRF_EMPTY_EVENTS_TEST";
|
||||
let test = format!("heal::mrf_queue::tests::empty_events_tests::{name}");
|
||||
if let Ok(current) = env::var(CHILD) {
|
||||
assert_eq!(current, test, "only the selected registry test may run in the child");
|
||||
return false;
|
||||
}
|
||||
let output = std::process::Command::new(env::current_exe().expect("test executable"))
|
||||
.args([&test, "--exact", "--test-threads=1"])
|
||||
.env(CHILD, &test)
|
||||
.output()
|
||||
.expect("run isolated MRF consumer test");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("1 passed; 0 failed"),
|
||||
"consumer child must execute exactly once: {stdout}{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn runtime() -> MrfRuntime {
|
||||
let config = MrfConsumerConfig::default();
|
||||
MrfRuntime {
|
||||
partial_writes: PartialWrites::default(),
|
||||
queue: MrfQueue::new(config.queue_capacity, config.journal_max_bytes),
|
||||
config,
|
||||
checkpoint_owner: Uuid::new_v4(),
|
||||
next_checkpoint_sequence: 1,
|
||||
new_since_flush: 0,
|
||||
dirty: false,
|
||||
journal_on_disk: false,
|
||||
retain_replay_journal: false,
|
||||
durable_replay_anchors: Vec::new(),
|
||||
replay_cleanup: None,
|
||||
runtime_checkpoint: None,
|
||||
backoff_until: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn partial_intent(bucket: &str) -> MrfIntent {
|
||||
MrfIntent {
|
||||
kind: MrfKind::PartialWrite,
|
||||
scope: Some(MrfScope {
|
||||
pool_index: 0,
|
||||
set_index: 0,
|
||||
}),
|
||||
..intent(bucket, "same-object", 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn proof(anchor: &MrfDurableRepairAnchor) -> MrfVerifiedRepairEvent {
|
||||
MrfVerifiedRepairEvent {
|
||||
kind: anchor.kind,
|
||||
bucket: anchor.bucket.clone(),
|
||||
object: anchor.object.clone(),
|
||||
version_id: anchor.version_id,
|
||||
scope: anchor.scope,
|
||||
delete_marker_purge: anchor.delete_marker_purge,
|
||||
lease: Some(anchor.lease),
|
||||
bucket_incarnation_id: anchor.bucket_incarnation_id,
|
||||
disposition: MrfVerifiedRepairDisposition::VerifiedHealthy,
|
||||
}
|
||||
}
|
||||
|
||||
struct CloneProbe {
|
||||
bucket: Arc<str>,
|
||||
counts: Mutex<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl metrics::GaugeFn for CloneProbe {
|
||||
fn increment(&self, _: f64) {
|
||||
panic!("discharge publishes absolute gauges");
|
||||
}
|
||||
fn decrement(&self, _: f64) {
|
||||
panic!("discharge publishes absolute gauges");
|
||||
}
|
||||
fn set(&self, _: f64) {
|
||||
self.counts
|
||||
.lock()
|
||||
.expect("probe samples")
|
||||
.push(Arc::strong_count(&self.bucket));
|
||||
}
|
||||
}
|
||||
|
||||
struct ProbeRecorder(Arc<CloneProbe>);
|
||||
|
||||
impl metrics::Recorder for ProbeRecorder {
|
||||
fn describe_counter(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_gauge(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_histogram(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn register_counter(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Counter {
|
||||
metrics::Counter::noop()
|
||||
}
|
||||
fn register_histogram(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Histogram {
|
||||
metrics::Histogram::noop()
|
||||
}
|
||||
fn register_gauge(&self, key: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Gauge {
|
||||
if key.name() == "rustfs_heal_mrf_queue_depth" {
|
||||
metrics::Gauge::from_arc(self.0.clone())
|
||||
} else {
|
||||
metrics::Gauge::noop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_events_avoid_anchor_clones_and_preserve_inner_publication() {
|
||||
if isolated("empty_events_avoid_anchor_clones_and_preserve_inner_publication") {
|
||||
return;
|
||||
}
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
let mut source = partial_intent("empty-proof-clone-probe");
|
||||
assert_eq!(
|
||||
rustfs_common::mrf_channel::try_rearm_mrf_replay_intent(&mut source),
|
||||
MrfIngressResult::Enqueued
|
||||
);
|
||||
let anchor = MrfDurableRepairAnchor::from_intent(&source, Uuid::new_v4()).expect("exact fixture anchor");
|
||||
let probe = Arc::new(CloneProbe {
|
||||
bucket: anchor.bucket.clone(),
|
||||
counts: Mutex::new(Vec::new()),
|
||||
});
|
||||
let recorder = ProbeRecorder(probe.clone());
|
||||
let mut runtime = runtime();
|
||||
runtime.durable_replay_anchors.push(anchor);
|
||||
let baseline = Arc::strong_count(&probe.bucket);
|
||||
metrics::with_local_recorder(&recorder, || runtime.discharge_durable_replay_anchors());
|
||||
assert_eq!(
|
||||
*probe.counts.lock().expect("probe samples"),
|
||||
vec![baseline],
|
||||
"the real inner publication must occur once without a temporary cloned anchor"
|
||||
);
|
||||
assert_eq!(runtime.durable_replay_anchors.len(), 1);
|
||||
assert!(!runtime.dirty);
|
||||
assert!(runtime.retained_replay_journal());
|
||||
probe.counts.lock().expect("clear probe").clear();
|
||||
runtime.durable_replay_anchors.clear();
|
||||
metrics::with_local_recorder(&recorder, || runtime.discharge_durable_replay_anchors());
|
||||
assert!(
|
||||
probe.counts.lock().expect("probe samples").is_empty(),
|
||||
"empty anchors never publish inside discharge"
|
||||
);
|
||||
runtime
|
||||
.admit_partial_write(partial_intent("empty-proof-old-none"), None)
|
||||
.expect("retain old unknown");
|
||||
let original = runtime.partial_writes.checkpoint_records(intent_digest);
|
||||
metrics::with_local_recorder(&recorder, || runtime.discharge_durable_replay_anchors());
|
||||
assert!(
|
||||
probe.counts.lock().expect("probe samples").is_empty(),
|
||||
"unknown-only records do not fabricate an anchor"
|
||||
);
|
||||
assert_eq!(runtime.partial_writes.checkpoint_records(intent_digest), original);
|
||||
rustfs_common::mrf_channel::release_mrf_intent(&source);
|
||||
}
|
||||
|
||||
async fn bound_runtime() -> (rustfs_test_utils::TestECStoreEnv, Arc<HealManager>, MrfRuntime, MrfDurableRepairAnchor) {
|
||||
let env = rustfs_test_utils::TestECStoreEnv::builder()
|
||||
.prefix("rustfs_mrf_empty_events")
|
||||
.build()
|
||||
.await;
|
||||
let bucket = "empty-event-responsibility";
|
||||
env.make_bucket(bucket, false).await;
|
||||
let storage: Arc<dyn HealStorageAPI> = Arc::new(ECStoreHealStorage::new(env.ecstore.clone()));
|
||||
let manager = Arc::new(HealManager::new_without_root_recovery_for_test(storage, None));
|
||||
let incarnation = env.ecstore.pools[0]
|
||||
.get_disks(0)
|
||||
.bucket_incarnation_id_from_disk(bucket)
|
||||
.await
|
||||
.expect("persisted bucket identity");
|
||||
let mut runtime = runtime();
|
||||
runtime
|
||||
.admit_partial_write(partial_intent(bucket), None)
|
||||
.expect("retain original source None");
|
||||
runtime
|
||||
.admit_partial_write(partial_intent(bucket), Some(incarnation))
|
||||
.expect("admit adjacent exact source");
|
||||
assert!(runtime.flush().await, "both identities need a real committed successor");
|
||||
runtime.dispatch(&manager).await;
|
||||
let anchor = runtime
|
||||
.partial_writes
|
||||
.anchors()
|
||||
.next()
|
||||
.expect("source-bound dispatch creates a real anchor")
|
||||
.clone();
|
||||
assert_eq!(runtime.partial_writes.depth(), 2);
|
||||
(env, manager, runtime, anchor)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_events_late_verified_overflow_keeps_real_journal_and_old_none() {
|
||||
if isolated("empty_events_late_verified_overflow_keeps_real_journal_and_old_none") {
|
||||
return;
|
||||
}
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime")
|
||||
.block_on(async {
|
||||
let (_env, manager, mut runtime, anchor) = bound_runtime().await;
|
||||
let unknown = runtime
|
||||
.partial_writes
|
||||
.checkpoint_records(intent_digest)
|
||||
.into_iter()
|
||||
.find(|record| record.source_bucket_incarnation_id.is_none())
|
||||
.expect("old unknown checkpoint");
|
||||
let before = runtime.partial_writes.checkpoint_records(intent_digest);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(runtime.partial_writes.checkpoint_records(intent_digest), before);
|
||||
note_mrf_verified_repair(proof(&anchor));
|
||||
for index in 0..4096 {
|
||||
note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||
bucket: Arc::from("foreign-overflow"),
|
||||
object: Arc::from(format!("foreign-{index}")),
|
||||
..proof(&anchor)
|
||||
});
|
||||
}
|
||||
assert!(
|
||||
rustfs_common::mrf_channel::take_mrf_verified_repair_events_for(&anchor.bucket).is_empty(),
|
||||
"the late proof really was evicted before this consumer opportunity"
|
||||
);
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(runtime.partial_writes.checkpoint_records(intent_digest), before);
|
||||
assert!(runtime.retained_replay_journal());
|
||||
assert_eq!(runtime.replay_cleanup_to_delete(), None);
|
||||
runtime.dispatch(&manager).await;
|
||||
assert_eq!(
|
||||
runtime.partial_writes.depth(),
|
||||
2,
|
||||
"a retry opportunity cannot acknowledge either identity"
|
||||
);
|
||||
note_mrf_verified_repair(proof(&anchor));
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(runtime.partial_writes.checkpoint_records(intent_digest), vec![unknown.clone()]);
|
||||
assert!(runtime.dirty);
|
||||
assert!(runtime.retained_replay_journal(), "old None still owns its checkpoint");
|
||||
assert!(runtime.flush().await);
|
||||
let mut queue = MrfQueue::new(runtime.config.queue_capacity, runtime.config.journal_max_bytes);
|
||||
let replay = replay_into(&manager, &mut queue, &mut None).await;
|
||||
assert_eq!(replay.partial_writes.len(), 1);
|
||||
assert_eq!(
|
||||
replay.partial_writes[0].1.as_ref(),
|
||||
Some(&unknown),
|
||||
"identity, None, state, enqueue time and operator audit survive the actual journal replay"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_events_late_legacy_only_overflow_parks_only_the_exact_source() {
|
||||
if isolated("empty_events_late_legacy_only_overflow_parks_only_the_exact_source") {
|
||||
return;
|
||||
}
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime")
|
||||
.block_on(async {
|
||||
let (_env, manager, mut runtime, anchor) = bound_runtime().await;
|
||||
let unknown = runtime
|
||||
.partial_writes
|
||||
.checkpoint_records(intent_digest)
|
||||
.into_iter()
|
||||
.find(|record| record.source_bucket_incarnation_id.is_none())
|
||||
.expect("old unknown checkpoint");
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
note_mrf_unverified_legacy(MrfUnverifiedLegacyEvent { anchor: anchor.clone() });
|
||||
for index in 0..4096 {
|
||||
note_mrf_unverified_legacy(MrfUnverifiedLegacyEvent {
|
||||
anchor: MrfDurableRepairAnchor {
|
||||
bucket: Arc::from("foreign-legacy-overflow"),
|
||||
object: Arc::from(format!("foreign-{index}")),
|
||||
..anchor.clone()
|
||||
},
|
||||
});
|
||||
}
|
||||
assert!(
|
||||
rustfs_common::mrf_channel::take_mrf_unverified_legacy_events_for(&anchor.bucket).is_empty(),
|
||||
"the typed legacy notice was actually evicted"
|
||||
);
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(runtime.partial_writes.lifecycle_summary().held_count, 0);
|
||||
assert_eq!(runtime.partial_writes.depth(), 2);
|
||||
assert!(runtime.retained_replay_journal());
|
||||
runtime.dispatch(&manager).await;
|
||||
note_mrf_unverified_legacy(MrfUnverifiedLegacyEvent { anchor });
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(
|
||||
runtime.partial_writes.lifecycle_summary().held_count,
|
||||
1,
|
||||
"legacy-only queue wakes the consumer"
|
||||
);
|
||||
assert_eq!(runtime.partial_writes.depth(), 2, "parking is never a discharge");
|
||||
assert!(runtime.partial_writes.checkpoint_records(intent_digest).contains(&unknown));
|
||||
assert!(runtime.dirty);
|
||||
assert!(runtime.flush().await, "the held lifecycle transition must be checkpointed");
|
||||
let mut queue = MrfQueue::new(runtime.config.queue_capacity, runtime.config.journal_max_bytes);
|
||||
let replay = replay_into(&manager, &mut queue, &mut None).await;
|
||||
assert_eq!(replay.partial_writes.len(), 2);
|
||||
assert!(
|
||||
replay
|
||||
.partial_writes
|
||||
.iter()
|
||||
.any(|(_, record)| record.as_ref() == Some(&unknown))
|
||||
);
|
||||
assert!(replay.partial_writes.iter().any(|(_, record)| {
|
||||
record
|
||||
.as_ref()
|
||||
.is_some_and(|record| matches!(record.state, partial_write::ResponsibilityState::HeldUnverifiedLegacy { .. }))
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_events_keep_dirty_flush_failure_retry_and_cleanup_gates() {
|
||||
if isolated("empty_events_keep_dirty_flush_failure_retry_and_cleanup_gates") {
|
||||
return;
|
||||
}
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime")
|
||||
.block_on(async {
|
||||
let (env, manager, mut runtime, _) = bound_runtime().await;
|
||||
let original = runtime.partial_writes.checkpoint_records(intent_digest);
|
||||
runtime.dirty = true;
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert!(matches!(
|
||||
tick_action(
|
||||
runtime.dirty,
|
||||
runtime.partial_writes.depth(),
|
||||
runtime.journal_on_disk,
|
||||
runtime.retained_replay_journal()
|
||||
),
|
||||
TickAction::Flush
|
||||
));
|
||||
let paths = env
|
||||
.disk_paths
|
||||
.iter()
|
||||
.map(|disk| (disk.join(".rustfs.sys"), disk.join(".mrf-empty-sys-backup")))
|
||||
.collect::<Vec<_>>();
|
||||
for (path, backup) in &paths {
|
||||
fs::rename(path, backup).expect("preserve the test's committed metadata before injecting failure");
|
||||
fs::write(path, b"block test metadata writes").expect("block the test metadata directory");
|
||||
}
|
||||
let persisted = runtime.flush().await;
|
||||
for (path, backup) in &paths {
|
||||
fs::remove_file(path).expect("remove test failure injection");
|
||||
fs::rename(backup, path).expect("restore the test's committed metadata");
|
||||
}
|
||||
assert!(!persisted, "a failed journal write must not become a checkpoint receipt");
|
||||
assert!(runtime.dirty);
|
||||
assert_eq!(runtime.partial_writes.checkpoint_records(intent_digest), original);
|
||||
assert!(runtime.retained_replay_journal());
|
||||
assert_eq!(runtime.replay_cleanup_to_delete(), None);
|
||||
assert!(runtime.flush().await, "the unchanged dirty path must retry after storage returns");
|
||||
assert!(!runtime.dirty);
|
||||
runtime.dispatch(&manager).await;
|
||||
let mut queue = MrfQueue::new(runtime.config.queue_capacity, runtime.config.journal_max_bytes);
|
||||
let replay = replay_into(&manager, &mut queue, &mut None).await;
|
||||
let mut restored = replay
|
||||
.partial_writes
|
||||
.into_iter()
|
||||
.map(|(_, record)| record.expect("lifecycle companion"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut original = original;
|
||||
restored.sort_by_key(|record| record.responsibility_id);
|
||||
original.sort_by_key(|record| record.responsibility_id);
|
||||
assert_eq!(restored, original, "failure and retry preserve both exact persisted responsibilities");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_events_real_consumer_stop_retains_unresolved_journal() {
|
||||
if isolated("empty_events_real_consumer_stop_retains_unresolved_journal") {
|
||||
return;
|
||||
}
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime")
|
||||
.block_on(async {
|
||||
let (_env, manager, mut runtime, anchor) = bound_runtime().await;
|
||||
let original = runtime.partial_writes.checkpoint_records(intent_digest);
|
||||
assert!(!mrf_durable_repair_events_may_be_pending());
|
||||
runtime.discharge_durable_replay_anchors();
|
||||
assert_eq!(runtime.partial_writes.checkpoint_records(intent_digest), original);
|
||||
drop(runtime);
|
||||
let (sender, receiver) = mpsc::channel(4);
|
||||
let (durable_sender, durable_receiver) = mpsc::channel(4);
|
||||
let (lifecycle_sender, lifecycle_receiver) = mpsc::channel(4);
|
||||
let consumer = tokio::spawn(run_mrf_consumer(manager.clone(), receiver, durable_receiver, lifecycle_receiver));
|
||||
let (response, listed) = tokio::sync::oneshot::channel();
|
||||
lifecycle_sender
|
||||
.send(MrfLifecycleCommand::List {
|
||||
after: None,
|
||||
limit: 10,
|
||||
response,
|
||||
})
|
||||
.await
|
||||
.expect("query the actual consumer");
|
||||
let listing = tokio::time::timeout(Duration::from_secs(10), listed)
|
||||
.await
|
||||
.expect("consumer startup finishes")
|
||||
.expect("listing receipt");
|
||||
assert!(
|
||||
listing
|
||||
.responsibilities
|
||||
.iter()
|
||||
.any(|row| row.source_bucket_incarnation_id.is_none()),
|
||||
"the actual consumer must restore the unknown source"
|
||||
);
|
||||
// A prior process's late proof must not be mistaken for a newly replayed
|
||||
// lease, even if shutdown wins before the next periodic consumption.
|
||||
note_mrf_verified_repair(proof(&anchor));
|
||||
drop(sender);
|
||||
tokio::time::timeout(Duration::from_secs(10), consumer)
|
||||
.await
|
||||
.expect("ordinary channel closure returns")
|
||||
.expect("consumer task");
|
||||
drop(durable_sender);
|
||||
drop(lifecycle_sender);
|
||||
let mut queue = MrfQueue::new(
|
||||
MrfConsumerConfig::default().queue_capacity,
|
||||
MrfConsumerConfig::default().journal_max_bytes,
|
||||
);
|
||||
let replay = replay_into(&manager, &mut queue, &mut None).await;
|
||||
let mut restored = replay
|
||||
.partial_writes
|
||||
.into_iter()
|
||||
.map(|(_, record)| record.expect("retained lifecycle companion"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut original = original;
|
||||
restored.sort_by_key(|record| record.responsibility_id);
|
||||
original.sort_by_key(|record| record.responsibility_id);
|
||||
assert_eq!(
|
||||
restored, original,
|
||||
"stop returns with exact unresolved data still recoverable, not an event-drain promise"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -156,6 +156,9 @@ pub enum ReadVersionContextBoundary {
|
||||
AppPutOwner,
|
||||
PutCommitOwner,
|
||||
TierFreeVersionSourceLookup,
|
||||
BucketTask,
|
||||
BackgroundTask,
|
||||
DetachedMutation,
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
@@ -181,6 +184,9 @@ impl ReadVersionContextBoundary {
|
||||
Self::AppPutOwner => "app_put_owner",
|
||||
Self::PutCommitOwner => "put_commit_owner",
|
||||
Self::TierFreeVersionSourceLookup => "tier_free_version_source_lookup",
|
||||
Self::BucketTask => "bucket_task",
|
||||
Self::BackgroundTask => "background_task",
|
||||
Self::DetachedMutation => "detached_mutation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2777,6 +2777,15 @@ pub struct AllocatorMemoryObservation {
|
||||
pub malloc_requested_peak_bytes: Option<u64>,
|
||||
pub malloc_requested_total_bytes: Option<u64>,
|
||||
pub heap_count: Option<u64>,
|
||||
/// Bytes submitted to allocator reset operations, including failed attempts.
|
||||
pub reset_bytes_total: Option<u64>,
|
||||
/// Bytes submitted to allocator purge operations, not OS-released RSS.
|
||||
pub purged_bytes_total: Option<u64>,
|
||||
pub commit_calls_total: Option<u64>,
|
||||
pub reset_calls_total: Option<u64>,
|
||||
pub purge_calls_total: Option<u64>,
|
||||
/// Arena purge passes after the allocator's eligibility checks.
|
||||
pub arena_purge_passes_total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Record allocator-level memory attribution when supported by the allocator.
|
||||
@@ -2785,7 +2794,10 @@ pub struct AllocatorMemoryObservation {
|
||||
/// when unavailable. Consumers also need observation progress to detect a stalled
|
||||
/// sampler across its configured sampling interval, rather than every scrape.
|
||||
/// The observation counter includes unavailable samples. These metric families
|
||||
/// do not form an atomic snapshot.
|
||||
/// do not form an atomic snapshot. Reclaim-stat availability means the field
|
||||
/// was present and valid, not that statistical accounting is enabled or that
|
||||
/// memory was successfully returned to the OS. Cumulative allocator statistics
|
||||
/// are sampled as gauges so a lower observation is not hidden by a counter.
|
||||
#[inline(always)]
|
||||
pub fn record_allocator_memory_observation(backend: &'static str, observation: AllocatorMemoryObservation) {
|
||||
if !metrics_enabled() {
|
||||
@@ -2825,6 +2837,43 @@ pub fn record_allocator_memory_observation(backend: &'static str, observation: A
|
||||
if let Some(heap_count) = observation.heap_count {
|
||||
gauge!("rustfs_memory_allocator_heap_count", "backend" => backend).set(heap_count as f64);
|
||||
}
|
||||
for (metric, availability_metric, value) in [
|
||||
(
|
||||
"rustfs_memory_allocator_reset_bytes_total",
|
||||
"rustfs_memory_allocator_reset_bytes_total_available",
|
||||
observation.reset_bytes_total,
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_purged_bytes_total",
|
||||
"rustfs_memory_allocator_purged_bytes_total_available",
|
||||
observation.purged_bytes_total,
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_commit_calls_total",
|
||||
"rustfs_memory_allocator_commit_calls_total_available",
|
||||
observation.commit_calls_total,
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_reset_calls_total",
|
||||
"rustfs_memory_allocator_reset_calls_total_available",
|
||||
observation.reset_calls_total,
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_purge_calls_total",
|
||||
"rustfs_memory_allocator_purge_calls_total_available",
|
||||
observation.purge_calls_total,
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_arena_purge_passes_total",
|
||||
"rustfs_memory_allocator_arena_purge_passes_total_available",
|
||||
observation.arena_purge_passes_total,
|
||||
),
|
||||
] {
|
||||
gauge!(availability_metric, "backend" => backend).set(if value.is_some() { 1.0 } else { 0.0 });
|
||||
if let Some(value) = value {
|
||||
gauge!(metric, "backend" => backend).set(value as f64);
|
||||
}
|
||||
}
|
||||
counter!("rustfs_memory_allocator_observations_total", "backend" => backend).increment(1);
|
||||
}
|
||||
|
||||
@@ -3282,6 +3331,106 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOCATOR_RECLAIM_TOTAL_METRICS: [(&str, &str); 6] = [
|
||||
(
|
||||
"rustfs_memory_allocator_reset_bytes_total",
|
||||
"rustfs_memory_allocator_reset_bytes_total_available",
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_purged_bytes_total",
|
||||
"rustfs_memory_allocator_purged_bytes_total_available",
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_commit_calls_total",
|
||||
"rustfs_memory_allocator_commit_calls_total_available",
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_reset_calls_total",
|
||||
"rustfs_memory_allocator_reset_calls_total_available",
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_purge_calls_total",
|
||||
"rustfs_memory_allocator_purge_calls_total_available",
|
||||
),
|
||||
(
|
||||
"rustfs_memory_allocator_arena_purge_passes_total",
|
||||
"rustfs_memory_allocator_arena_purge_passes_total_available",
|
||||
),
|
||||
];
|
||||
|
||||
fn allocator_reclaim_observation(values: [Option<u64>; 6]) -> AllocatorMemoryObservation {
|
||||
AllocatorMemoryObservation {
|
||||
reset_bytes_total: values[0],
|
||||
purged_bytes_total: values[1],
|
||||
commit_calls_total: values[2],
|
||||
reset_calls_total: values[3],
|
||||
purge_calls_total: values[4],
|
||||
arena_purge_passes_total: values[5],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocator_reclaim_totals_track_presence_zero_decrease_and_recovery() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sequence = [
|
||||
[None; 6],
|
||||
[Some(8192), Some(4096), Some(30), Some(20), Some(10), Some(5)],
|
||||
[None, Some(2048), None, Some(15), None, Some(4)],
|
||||
[Some(0); 6],
|
||||
[None; 6],
|
||||
[Some(1024), Some(2048), Some(3), Some(4), Some(5), Some(6)],
|
||||
];
|
||||
for (index, current) in sequence.iter().enumerate() {
|
||||
// Recorder snapshots consume values; replay each prefix before its only snapshot.
|
||||
let recorder = DebuggingRecorder::new();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
set_metrics_enabled(true);
|
||||
for values in sequence.iter().take(index + 1) {
|
||||
record_allocator_memory_observation("mimalloc", allocator_reclaim_observation(*values));
|
||||
}
|
||||
set_metrics_enabled(false);
|
||||
});
|
||||
let rows = recorder.snapshotter().snapshot().into_vec();
|
||||
for (field, (metric, availability_metric)) in ALLOCATOR_RECLAIM_TOTAL_METRICS.iter().enumerate() {
|
||||
let expected = sequence[..=index]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|values| values[field])
|
||||
.map(|value| value as f64);
|
||||
assert_eq!(gauge_value(&rows, metric), expected, "sample {index}, {metric}");
|
||||
assert_eq!(
|
||||
gauge_value(&rows, availability_metric),
|
||||
Some(if current[field].is_some() { 1.0 } else { 0.0 }),
|
||||
"sample {index}, {availability_metric}",
|
||||
);
|
||||
}
|
||||
assert_eq!(gauge_value(&rows, "rustfs_memory_allocator_reserved_bytes_available"), Some(0.0));
|
||||
assert_eq!(gauge_value(&rows, "rustfs_memory_allocator_committed_bytes_available"), Some(0.0));
|
||||
assert_eq!(gauge_value(&rows, "rustfs_memory_allocator_malloc_requested_bytes_available"), Some(0.0));
|
||||
assert_eq!(
|
||||
counter_total(&rows, "rustfs_memory_allocator_observations_total"),
|
||||
Some(u64::try_from(index + 1).expect("sample count fits in u64")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocator_reclaim_totals_respect_disabled_recording() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
set_metrics_enabled(false);
|
||||
record_allocator_memory_observation("mimalloc", allocator_reclaim_observation([Some(128); 6]));
|
||||
});
|
||||
let rows = recorder.snapshotter().snapshot().into_vec();
|
||||
for (metric, availability_metric) in ALLOCATOR_RECLAIM_TOTAL_METRICS {
|
||||
assert_eq!(gauge_value(&rows, metric), None);
|
||||
assert_eq!(gauge_value(&rows, availability_metric), None);
|
||||
}
|
||||
assert_eq!(counter_total(&rows, "rustfs_memory_allocator_observations_total"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocator_observation_progress_includes_unavailable_samples() {
|
||||
let _guard = METRICS_FLAG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
//! External S3 HTTP outcomes, including requests rejected before S3 dispatch.
|
||||
//! Admin snapshots and metric exporters share these counters. The older
|
||||
//! operation counter counts handler entries and is not an HTTP denominator.
|
||||
//!
|
||||
//! Entry-to-dispatch wall time starts at the external request guard and ends
|
||||
//! at its first typed S3 dispatch, or at response/error/cancellation when no
|
||||
//! operation was dispatched. It includes authentication, parsing and scheduler
|
||||
//! waits within that boundary; it is not pure queue time or body-stream time.
|
||||
//! Timing is enabled only when the general metrics gate is on at entry.
|
||||
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
use std::cell::Cell;
|
||||
@@ -31,6 +37,7 @@ pub type S3HttpCompletionObserverEnabled = fn() -> bool;
|
||||
|
||||
const METRIC: &str = "rustfs_s3_http_requests_total";
|
||||
const LOCK_FAILURE_METRIC: &str = "rustfs_s3_lock_failures_total";
|
||||
const ENTRY_TO_DISPATCH_METRIC: &str = "rustfs_s3_http_entry_to_dispatch_duration_seconds";
|
||||
const METHODS: [&str; 10] = [
|
||||
"GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS", "PATCH", "CONNECT", "TRACE", "OTHER",
|
||||
];
|
||||
@@ -63,7 +70,7 @@ pub fn current_s3_http_operation_context() -> Option<S3HttpOperationContext> {
|
||||
}
|
||||
CURRENT_OPERATION
|
||||
.try_with(|current| {
|
||||
let index = current.get();
|
||||
let index = current.get().operation;
|
||||
S3Operation::ALL.get(index).copied().map(S3HttpOperationContext)
|
||||
})
|
||||
.ok()
|
||||
@@ -85,7 +92,9 @@ where
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
return with_isolated_s3_http_operation_context(Some(context), future).await;
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
CURRENT_OPERATION.scope(Cell::new(context.operation_index()), future).await
|
||||
CURRENT_OPERATION
|
||||
.scope(Cell::new(RequestScopeState::operation_only(context.operation_index())), future)
|
||||
.await
|
||||
}
|
||||
None => future.await,
|
||||
}
|
||||
@@ -102,7 +111,11 @@ where
|
||||
ISOLATED_OPERATION
|
||||
.scope(Cell::new(index), async {
|
||||
match context {
|
||||
Some(context) => CURRENT_OPERATION.scope(Cell::new(context.operation_index()), future).await,
|
||||
Some(context) => {
|
||||
CURRENT_OPERATION
|
||||
.scope(Cell::new(RequestScopeState::operation_only(context.operation_index())), future)
|
||||
.await
|
||||
}
|
||||
None => future.await,
|
||||
}
|
||||
})
|
||||
@@ -114,8 +127,24 @@ tokio::task_local! {
|
||||
static ISOLATED_OPERATION: Cell<usize>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RequestScopeState {
|
||||
operation: usize,
|
||||
entry_started: Option<Instant>,
|
||||
}
|
||||
|
||||
impl RequestScopeState {
|
||||
// A child operation carries identity, never ownership of its parent's timer.
|
||||
fn operation_only(operation: usize) -> Self {
|
||||
Self {
|
||||
operation,
|
||||
entry_started: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task_local! {
|
||||
static CURRENT_OPERATION: Cell<usize>;
|
||||
static CURRENT_OPERATION: Cell<RequestScopeState>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -184,8 +213,14 @@ pub fn observe_s3_http_operation(op: S3Operation) {
|
||||
let _ = CURRENT_OPERATION.try_with(|current| {
|
||||
// Internal operations must not overwrite the external request's first
|
||||
// dispatched operation. No task-local scope means non-HTTP work.
|
||||
if current.get() == UNKNOWN_OPERATION {
|
||||
current.set(op.metric_index());
|
||||
let mut state = current.get();
|
||||
if state.operation == UNKNOWN_OPERATION {
|
||||
state.operation = op.metric_index();
|
||||
let started = state.entry_started.take();
|
||||
current.set(state);
|
||||
if let Some(started) = started {
|
||||
record_entry_to_dispatch(op.metric_index(), "dispatched", started);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -196,7 +231,7 @@ pub fn record_s3_http_lock_failure(resource_class: &'static str, mode: &'static
|
||||
let _ = CURRENT_OPERATION.try_with(|current| {
|
||||
counter!(
|
||||
LOCK_FAILURE_METRIC,
|
||||
"op" => operation_label(current.get()),
|
||||
"op" => operation_label(current.get().operation),
|
||||
"resource_class" => resource_class,
|
||||
"mode" => mode,
|
||||
"reason" => reason
|
||||
@@ -213,6 +248,7 @@ pub struct S3HttpRequestGuard {
|
||||
operation: usize,
|
||||
completion: Option<(S3HttpCompletionObserver, Instant)>,
|
||||
finished: bool,
|
||||
entry_started: Option<Instant>,
|
||||
}
|
||||
|
||||
impl S3HttpRequestGuard {
|
||||
@@ -226,8 +262,9 @@ impl S3HttpRequestGuard {
|
||||
operation: UNKNOWN_OPERATION,
|
||||
completion: COMPLETION_OBSERVER
|
||||
.get()
|
||||
.and_then(|(enabled, observer)| enabled().then_some((*observer, Instant::now()))),
|
||||
.and_then(|(enabled, observer)| enabled().then(|| (*observer, Instant::now()))),
|
||||
finished: false,
|
||||
entry_started: crate::metrics_enabled().then(Instant::now),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,10 +278,23 @@ impl S3HttpRequestGuard {
|
||||
/// Attribute existing operation instrumentation without changing S3
|
||||
/// handlers or propagating metric labels through storage/RPC contracts.
|
||||
pub fn in_scope<T>(&mut self, f: impl FnOnce() -> T) -> T {
|
||||
CURRENT_OPERATION.sync_scope(Cell::new(self.operation), || {
|
||||
let result = f();
|
||||
self.operation = CURRENT_OPERATION.with(Cell::get);
|
||||
result
|
||||
// Copy back even while unwinding: a dispatch already consumed this
|
||||
// entry timer and must not be emitted again by the outer drop guard.
|
||||
struct CopyBack<'a>(&'a mut S3HttpRequestGuard);
|
||||
impl Drop for CopyBack<'_> {
|
||||
fn drop(&mut self) {
|
||||
let state = CURRENT_OPERATION.with(Cell::get);
|
||||
self.0.operation = state.operation;
|
||||
self.0.entry_started = state.entry_started;
|
||||
}
|
||||
}
|
||||
let state = RequestScopeState {
|
||||
operation: self.operation,
|
||||
entry_started: self.entry_started,
|
||||
};
|
||||
CURRENT_OPERATION.sync_scope(Cell::new(state), || {
|
||||
let _copy_back = CopyBack(self);
|
||||
f()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,6 +312,9 @@ impl S3HttpRequestGuard {
|
||||
|
||||
fn finish(&mut self, outcome: usize) {
|
||||
if !self.finished {
|
||||
if let Some(started) = self.entry_started.take() {
|
||||
record_entry_to_dispatch(UNKNOWN_OPERATION, OUTCOMES[outcome], started);
|
||||
}
|
||||
COUNTERS.record(self.method, self.operation, outcome);
|
||||
if let Some(((observer, started_at), operation)) = self.completion.take().zip(S3Operation::ALL.get(self.operation)) {
|
||||
observer(*operation, started_at.elapsed(), outcome == 1);
|
||||
@@ -271,6 +324,13 @@ impl S3HttpRequestGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn record_entry_to_dispatch(operation: usize, outcome: &'static str, started: Instant) {
|
||||
// The entry gate owns this sample. A later gate change must neither lose an
|
||||
// enabled request nor start timing an already-running disabled request.
|
||||
histogram!(ENTRY_TO_DISPATCH_METRIC, "op" => operation_label(operation), "outcome" => outcome)
|
||||
.record(started.elapsed().as_secs_f64());
|
||||
}
|
||||
|
||||
pub fn install_s3_http_completion_observer(enabled: S3HttpCompletionObserverEnabled, observer: S3HttpCompletionObserver) {
|
||||
let _ = COMPLETION_OBSERVER.set((enabled, observer));
|
||||
}
|
||||
@@ -291,6 +351,278 @@ mod tests {
|
||||
use metrics::with_local_recorder;
|
||||
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
||||
|
||||
use crate::tests::METRICS_FLAG_LOCK;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct RestoreMetricsGate(bool);
|
||||
|
||||
impl Drop for RestoreMetricsGate {
|
||||
fn drop(&mut self) {
|
||||
crate::set_metrics_enabled(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_samples(recorder: &DebuggingRecorder) -> BTreeMap<(String, String), Vec<f64>> {
|
||||
recorder
|
||||
.snapshotter()
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.filter(|(key, _, _, _)| key.key().name() == ENTRY_TO_DISPATCH_METRIC)
|
||||
.map(|(key, _, _, value)| {
|
||||
let labels: BTreeMap<_, _> = key.key().labels().map(|label| (label.key(), label.value())).collect();
|
||||
assert_eq!(labels.keys().copied().collect::<Vec<_>>(), ["op", "outcome"]);
|
||||
let DebugValue::Histogram(samples) = value else {
|
||||
panic!("entry-to-dispatch metric must be a histogram");
|
||||
};
|
||||
(
|
||||
(labels["op"].to_owned(), labels["outcome"].to_owned()),
|
||||
samples.into_iter().map(|sample| sample.0).collect(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_to_dispatch_stops_at_first_typed_dispatch_across_polls() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
crate::set_metrics_enabled(true);
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
let started = Instant::now() - Duration::from_secs(2);
|
||||
request.entry_started = Some(started);
|
||||
request.in_scope(|| assert_eq!(current_s3_http_operation_context(), None));
|
||||
assert_eq!(request.entry_started, Some(started));
|
||||
assert!(entry_samples(&recorder).is_empty());
|
||||
request.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::PutObject);
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
});
|
||||
assert!(request.entry_started.is_none());
|
||||
let dispatched = entry_samples(&recorder);
|
||||
assert_eq!(dispatched.len(), 1);
|
||||
let samples = &dispatched[&("s3:PutObject".to_owned(), "dispatched".to_owned())];
|
||||
assert_eq!(samples.len(), 1);
|
||||
assert!(samples[0] >= 2.0, "wall time must include pre-dispatch polls");
|
||||
request.in_scope(|| observe_s3_http_operation(S3Operation::DeleteObject));
|
||||
request.response(200);
|
||||
request.service_error();
|
||||
drop(request);
|
||||
// DebuggingRecorder drains histogram samples on snapshot. The
|
||||
// dispatch snapshot above held exactly one sample; completion must
|
||||
// not produce another sample after that drain. Empty registered
|
||||
// series remain in the snapshot and are not additional samples.
|
||||
assert!(
|
||||
entry_samples(&recorder).values().all(Vec::is_empty),
|
||||
"completion cannot extend or duplicate dispatch timing"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_to_dispatch_records_undispatched_response_error_and_drop_once() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
crate::set_metrics_enabled(true);
|
||||
let mut rejected = S3HttpRequestGuard::new("GET");
|
||||
rejected.in_scope(|| assert!(S3HttpRequestGuard::is_active()));
|
||||
rejected.response(403);
|
||||
rejected.service_error();
|
||||
drop(rejected);
|
||||
let mut failed = S3HttpRequestGuard::new("DELETE");
|
||||
failed.service_error();
|
||||
failed.response(500);
|
||||
drop(failed);
|
||||
drop(S3HttpRequestGuard::new("POST"));
|
||||
});
|
||||
let samples = entry_samples(&recorder);
|
||||
assert_eq!(samples.len(), 3);
|
||||
for outcome in ["4xx", "service_error", "cancelled"] {
|
||||
let row = &samples[&("unknown".to_owned(), outcome.to_owned())];
|
||||
assert_eq!(row.len(), 1, "{outcome} must be terminal exactly once");
|
||||
assert!(row[0].is_finite() && row[0] >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_to_dispatch_captures_gate_at_entry() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
crate::set_metrics_enabled(false);
|
||||
let mut disabled = S3HttpRequestGuard::new("GET");
|
||||
assert!(disabled.entry_started.is_none(), "disabled entry must not read the clock");
|
||||
crate::set_metrics_enabled(true);
|
||||
disabled.in_scope(|| observe_s3_http_operation(S3Operation::GetObject));
|
||||
disabled.response(200);
|
||||
assert!(entry_samples(&recorder).is_empty(), "enabling mid-request cannot create a partial sample");
|
||||
let mut dispatched = S3HttpRequestGuard::new("PUT");
|
||||
let mut undispatched = S3HttpRequestGuard::new("DELETE");
|
||||
crate::set_metrics_enabled(false);
|
||||
dispatched.in_scope(|| observe_s3_http_operation(S3Operation::PutObject));
|
||||
dispatched.response(204);
|
||||
undispatched.response(503);
|
||||
});
|
||||
let samples = entry_samples(&recorder);
|
||||
assert_eq!(samples.len(), 2, "disabling mid-request must preserve both enabled entry samples");
|
||||
assert_eq!(samples[&("s3:PutObject".to_owned(), "dispatched".to_owned())].len(), 1);
|
||||
assert_eq!(samples[&("unknown".to_owned(), "5xx".to_owned())].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_to_dispatch_nested_disabled_request_does_not_consume_outer_timer() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
crate::set_metrics_enabled(true);
|
||||
let mut outer = S3HttpRequestGuard::new("PUT");
|
||||
let outer_started = outer.entry_started;
|
||||
outer.in_scope(|| {
|
||||
crate::set_metrics_enabled(false);
|
||||
let mut inner = S3HttpRequestGuard::new("GET");
|
||||
inner.in_scope(|| observe_s3_http_operation(S3Operation::GetObject));
|
||||
inner.response(200);
|
||||
let current = CURRENT_OPERATION.with(Cell::get);
|
||||
assert_eq!(current.operation, UNKNOWN_OPERATION);
|
||||
assert_eq!(current.entry_started, outer_started);
|
||||
observe_s3_http_operation(S3Operation::PutObject);
|
||||
});
|
||||
outer.response(204);
|
||||
});
|
||||
let samples = entry_samples(&recorder);
|
||||
assert_eq!(samples.len(), 1);
|
||||
assert_eq!(samples[&("s3:PutObject".to_owned(), "dispatched".to_owned())].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_to_dispatch_nested_unwind_consumes_each_timer_once() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
crate::set_metrics_enabled(true);
|
||||
let mut outer = S3HttpRequestGuard::new("PUT");
|
||||
outer.in_scope(|| {
|
||||
let failed = std::panic::catch_unwind(|| {
|
||||
let mut inner = S3HttpRequestGuard::new("GET");
|
||||
inner.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
panic!("dispatch then unwind");
|
||||
});
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
assert_eq!(current_s3_http_operation_context(), None, "outer operation must be restored");
|
||||
assert!(CURRENT_OPERATION.with(Cell::get).entry_started.is_some());
|
||||
observe_s3_http_operation(S3Operation::PutObject);
|
||||
});
|
||||
outer.response(200);
|
||||
assert!(!S3HttpRequestGuard::is_active());
|
||||
});
|
||||
let samples = entry_samples(&recorder);
|
||||
assert_eq!(samples.len(), 2, "unwinding cannot emit a second unknown/cancelled sample");
|
||||
for op in ["s3:GetObject", "s3:PutObject"] {
|
||||
assert_eq!(samples[&(op.to_owned(), "dispatched".to_owned())].len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_to_dispatch_child_context_carries_no_parent_timer() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
crate::set_metrics_enabled(true);
|
||||
let mut origin = S3HttpRequestGuard::new("GET");
|
||||
let context = origin.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
current_s3_http_operation_context().expect("typed origin operation")
|
||||
});
|
||||
origin.response(200);
|
||||
let mut outer = S3HttpRequestGuard::new("PUT");
|
||||
outer.in_scope(|| {
|
||||
let outer_started = CURRENT_OPERATION.with(Cell::get).entry_started;
|
||||
let mut child = std::pin::pin!(with_s3_http_operation_context(Some(context), async {
|
||||
assert_eq!(current_s3_http_operation_name(), Some("s3:GetObject"));
|
||||
assert!(CURRENT_OPERATION.with(Cell::get).entry_started.is_none());
|
||||
observe_s3_http_operation(S3Operation::DeleteObject);
|
||||
}));
|
||||
assert!(
|
||||
child
|
||||
.as_mut()
|
||||
.poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
|
||||
.is_ready()
|
||||
);
|
||||
assert_eq!(CURRENT_OPERATION.with(Cell::get).entry_started, outer_started);
|
||||
observe_s3_http_operation(S3Operation::PutObject);
|
||||
});
|
||||
outer.response(200);
|
||||
});
|
||||
let samples = entry_samples(&recorder);
|
||||
assert_eq!(samples.len(), 2);
|
||||
assert_eq!(samples[&("s3:GetObject".to_owned(), "dispatched".to_owned())].len(), 1);
|
||||
assert_eq!(samples[&("s3:PutObject".to_owned(), "dispatched".to_owned())].len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[test]
|
||||
fn entry_to_dispatch_absent_operation_scope_preserves_ambient_timer_across_polls() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
let recorder = DebuggingRecorder::new();
|
||||
with_local_recorder(&recorder, || {
|
||||
crate::set_metrics_enabled(true);
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
let started = request.entry_started;
|
||||
let mut polls = 0;
|
||||
let mut isolated = Box::pin(with_isolated_s3_http_operation_context(
|
||||
None,
|
||||
std::future::poll_fn(|_| {
|
||||
polls += 1;
|
||||
assert_eq!(current_s3_http_operation_context(), None);
|
||||
assert!(S3HttpRequestGuard::is_active());
|
||||
let ambient = CURRENT_OPERATION.with(Cell::get);
|
||||
assert_eq!(ambient.operation, UNKNOWN_OPERATION);
|
||||
assert_eq!(ambient.entry_started, started);
|
||||
if polls == 1 {
|
||||
std::task::Poll::Pending
|
||||
} else {
|
||||
std::task::Poll::Ready(17)
|
||||
}
|
||||
}),
|
||||
));
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
request.in_scope(|| assert!(isolated.as_mut().poll(&mut cx).is_pending()));
|
||||
assert_eq!(request.entry_started, started);
|
||||
assert!(entry_samples(&recorder).is_empty());
|
||||
request.in_scope(|| assert_eq!(isolated.as_mut().poll(&mut cx), std::task::Poll::Ready(17)));
|
||||
drop(isolated);
|
||||
assert_eq!(polls, 2);
|
||||
assert_eq!(request.entry_started, started);
|
||||
assert!(
|
||||
ISOLATED_OPERATION.try_with(Cell::get).is_err(),
|
||||
"absence scope must restore its task local"
|
||||
);
|
||||
assert!(
|
||||
entry_samples(&recorder).is_empty(),
|
||||
"the absence scope itself cannot consume the ambient timer"
|
||||
);
|
||||
request.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::PutObject);
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
});
|
||||
request.response(200);
|
||||
});
|
||||
let samples = entry_samples(&recorder);
|
||||
assert_eq!(samples.len(), 1);
|
||||
assert_eq!(samples[&("s3:PutObject".to_owned(), "dispatched".to_owned())].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_observer_sees_each_dispatched_request_once() {
|
||||
static SEEN: std::sync::Mutex<Vec<(S3Operation, bool)>> = std::sync::Mutex::new(Vec::new());
|
||||
@@ -338,6 +670,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn observing_dispatch_does_not_count_a_second_handler_entry() {
|
||||
let _lock = METRICS_FLAG_LOCK.lock().expect("metrics gate lock");
|
||||
let _restore = RestoreMetricsGate(crate::metrics_enabled());
|
||||
crate::set_metrics_enabled(false);
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
with_local_recorder(&recorder, || {
|
||||
|
||||
@@ -173,3 +173,6 @@ rustls = { workspace = true, default-features = false, features = ["aws-lc-rs",
|
||||
rustls-pki-types.workspace = true
|
||||
tokio = { workspace = true, features = ["io-util", "net"] }
|
||||
tokio-rustls = { workspace = true, default-features = false, features = ["aws-lc-rs"] }
|
||||
|
||||
[target.'cfg(all(target_os = "linux", target_arch = "x86_64"))'.dev-dependencies]
|
||||
rustfs-common = { workspace = true, features = ["test-util"] }
|
||||
|
||||
@@ -149,3 +149,41 @@ async fn missing_identity_recovers_after_publication_with_netlink_denied() {
|
||||
assert_eq!(fallback, "127.0.0.1");
|
||||
assert_eq!(published, "node-b:9000");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contended_identity_uses_denied_netlink_fallback_then_recovers() {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
let action = libc::SECCOMP_RET_ERRNO | u32::try_from(libc::EPERM).expect("EPERM fits seccomp errno data");
|
||||
let (unavailable, resolved_while_contended, reader_result, published) = with_faulted_worker("node-a:9000", action, || {
|
||||
let (unavailable, resolved_while_contended, reader) =
|
||||
rustfs_common::with_local_node_name_write_lock_for_test("node-b:9000", || {
|
||||
let unavailable = rustfs_common::try_get_global_local_node_name().is_none();
|
||||
let (resolved_tx, resolved_rx) = mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let address_missing = rustfs_utils::get_local_ip().is_none();
|
||||
let identity = current_local_node_identity();
|
||||
resolved_tx
|
||||
.send((address_missing, identity))
|
||||
.expect("identity test must retain its result receiver");
|
||||
});
|
||||
|
||||
let resolved_while_contended = resolved_rx.recv_timeout(Duration::from_secs(30));
|
||||
(unavailable, resolved_while_contended, reader)
|
||||
});
|
||||
// The helper releases the writer before joining even if the reader
|
||||
// times out, so restoration and worker cleanup can still complete.
|
||||
let reader_result = reader.join();
|
||||
(unavailable, resolved_while_contended, reader_result, current_local_node_identity())
|
||||
})
|
||||
.await;
|
||||
|
||||
reader_result.expect("faulted identity reader should complete after writer release");
|
||||
assert!(unavailable, "the real nonblocking getter must observe writer contention");
|
||||
let (address_missing, fallback) =
|
||||
resolved_while_contended.expect("identity resolution must finish while the name writer is still held");
|
||||
assert!(address_missing, "the child must inherit the worker's real netlink denial");
|
||||
assert_eq!(fallback, "127.0.0.1");
|
||||
assert_eq!(published, "node-b:9000");
|
||||
}
|
||||
|
||||
@@ -3095,6 +3095,7 @@ where
|
||||
}
|
||||
let cycle_ctx = ctx.child_token();
|
||||
let cycle_budget = ScannerCycleBudget::new_with_runtime_progress_tracking(&cycle_ctx, scanner_cycle_budget_config());
|
||||
record_scanner_cycle_attempt(None);
|
||||
let initial_outcome = match await_scanner_cycle_with_budget_fence(
|
||||
&cycle_ctx,
|
||||
&cycle_budget,
|
||||
@@ -3335,6 +3336,7 @@ where
|
||||
|| probe_scanner_activity(storeapi.as_ref(), distributed),
|
||||
)
|
||||
.await;
|
||||
record_scanner_cycle_wakeup(wake_reason);
|
||||
scanner_activity_backoff_blocked =
|
||||
scanner_activity_backoff_blocked_after_wake(scanner_activity_backoff_blocked, wake_reason);
|
||||
match wake_reason {
|
||||
@@ -3423,6 +3425,7 @@ where
|
||||
let dirty_generation_before_cycle = dirty_usage_generation();
|
||||
let cycle_ctx = ctx.child_token();
|
||||
let cycle_budget = ScannerCycleBudget::new_with_runtime_progress_tracking(&cycle_ctx, scanner_cycle_budget_config());
|
||||
record_scanner_cycle_attempt(Some(wake_reason));
|
||||
let outcome = match await_scanner_cycle_with_budget_fence(
|
||||
&cycle_ctx,
|
||||
&cycle_budget,
|
||||
|
||||
@@ -32,6 +32,46 @@ pub(super) enum ScannerCycleWakeReason {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
// Literal labels keep recorder keys bounded and avoid allocating label vectors
|
||||
// when telemetry is not installed. Only the outer scheduler records wakeups;
|
||||
// activity-poll timer slices are not independent scheduler wakeups.
|
||||
fn record_scanner_cycle_trigger(reason: ScannerCycleWakeReason, attempt: bool) {
|
||||
macro_rules! record {
|
||||
($reason:literal) => {
|
||||
if attempt {
|
||||
metrics::counter!("rustfs_scanner_cycle_attempts_total", "reason" => $reason).increment(1);
|
||||
} else {
|
||||
metrics::counter!("rustfs_scanner_cycle_wakeups_total", "reason" => $reason).increment(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
match reason {
|
||||
ScannerCycleWakeReason::Timer => record!("timer"),
|
||||
ScannerCycleWakeReason::DirtyUsage => record!("dirty_usage"),
|
||||
ScannerCycleWakeReason::MovementGeneration => record!("movement_generation"),
|
||||
ScannerCycleWakeReason::ClusterActivity => record!("cluster_activity"),
|
||||
ScannerCycleWakeReason::ClusterMaintenance => record!("cluster_maintenance"),
|
||||
ScannerCycleWakeReason::ClusterActivityUnavailable => record!("cluster_activity_unavailable"),
|
||||
ScannerCycleWakeReason::RuntimeConfig => record!("runtime_config"),
|
||||
ScannerCycleWakeReason::MaintenanceConfig => record!("maintenance_config"),
|
||||
ScannerCycleWakeReason::Recovery => record!("recovery"),
|
||||
ScannerCycleWakeReason::LeaderLockLost => record!("leader_lock_lost"),
|
||||
ScannerCycleWakeReason::Cancelled => record!("cancelled"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_scanner_cycle_wakeup(reason: ScannerCycleWakeReason) {
|
||||
record_scanner_cycle_trigger(reason, false);
|
||||
}
|
||||
|
||||
pub(super) fn record_scanner_cycle_attempt(reason: Option<ScannerCycleWakeReason>) {
|
||||
if let Some(reason) = reason {
|
||||
record_scanner_cycle_trigger(reason, true);
|
||||
} else {
|
||||
metrics::counter!("rustfs_scanner_cycle_attempts_total", "reason" => "initial").increment(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ScannerCycleOutcome {
|
||||
Completed,
|
||||
|
||||
@@ -1401,9 +1401,10 @@ pub fn register_scanner_pause_backlog_retirement() {
|
||||
fn decode_scanner_pause_backlog_retirement_replicas(
|
||||
source_pool_index: usize,
|
||||
replicas: &[ScannerPauseBacklogRetirementReplica],
|
||||
) -> Result<Vec<ScannerPauseBacklogReplica>, ScannerPauseBacklogRetirementError> {
|
||||
) -> Result<(Vec<ScannerPauseBacklogReplica>, Vec<ScannerPauseBacklogReplica>), ScannerPauseBacklogRetirementError> {
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut decoded = Vec::with_capacity(replicas.len());
|
||||
let mut surviving = Vec::new();
|
||||
let mut has_source = false;
|
||||
for replica in replicas {
|
||||
let id = ScannerPauseBacklogReplicaId {
|
||||
@@ -1433,28 +1434,33 @@ fn decode_scanner_pause_backlog_retirement_replicas(
|
||||
));
|
||||
}
|
||||
};
|
||||
decoded.push(ScannerPauseBacklogReplica {
|
||||
let decoded_replica = ScannerPauseBacklogReplica {
|
||||
id,
|
||||
revision: None,
|
||||
state,
|
||||
});
|
||||
};
|
||||
if replica.pool_index != source_pool_index && replica.writable {
|
||||
surviving.push(decoded_replica.clone());
|
||||
}
|
||||
decoded.push(decoded_replica);
|
||||
}
|
||||
if !has_source {
|
||||
return Err(scanner_pause_backlog_retirement_invalid_record(
|
||||
"scanner pause backlog retirement has no native source record",
|
||||
));
|
||||
}
|
||||
Ok(decoded)
|
||||
Ok((decoded, surviving))
|
||||
}
|
||||
|
||||
fn verify_scanner_pause_backlog_retirement(
|
||||
source_pool_index: usize,
|
||||
replicas: &[ScannerPauseBacklogRetirementReplica],
|
||||
) -> Result<(), ScannerPauseBacklogRetirementError> {
|
||||
let mut decoded = decode_scanner_pause_backlog_retirement_replicas(source_pool_index, replicas)?;
|
||||
if decoded.iter().any(|replica| {
|
||||
replica.id.pool_index != source_pool_index && matches!(replica.state, ScannerPauseBacklogReplicaState::Missing)
|
||||
}) {
|
||||
let (mut decoded, surviving) = decode_scanner_pause_backlog_retirement_replicas(source_pool_index, replicas)?;
|
||||
if surviving
|
||||
.iter()
|
||||
.any(|replica| matches!(replica.state, ScannerPauseBacklogReplicaState::Missing))
|
||||
{
|
||||
return Err(scanner_pause_backlog_retirement_authority_conflict(
|
||||
"scanner pause backlog retirement has a missing surviving replica",
|
||||
));
|
||||
@@ -1462,11 +1468,6 @@ fn verify_scanner_pause_backlog_retirement(
|
||||
// Earlier entries may already have removed a source sibling after a
|
||||
// successful handoff. It contributes no stored authority to final cleanup.
|
||||
decoded.retain(|replica| !matches!(replica.state, ScannerPauseBacklogReplicaState::Missing));
|
||||
let surviving = decoded
|
||||
.iter()
|
||||
.filter(|replica| replica.id.pool_index != source_pool_index)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let selected = select_scanner_pause_backlog_replicas(surviving)
|
||||
.map_err(|failure| scanner_pause_backlog_retirement_selection_error(failure.error))?;
|
||||
if !selected.durable || !selected.stable_matches_ledger {
|
||||
@@ -1490,12 +1491,7 @@ fn plan_scanner_pause_backlog_retirement(
|
||||
) -> Result<Option<ScannerPauseBacklogRetirementPlan>, ScannerPauseBacklogRetirementError> {
|
||||
// Missing entries stay in the native selection. Omitting them could turn
|
||||
// an incomplete old cohort into a fabricated stable consensus.
|
||||
let decoded = decode_scanner_pause_backlog_retirement_replicas(source_pool_index, replicas)?;
|
||||
let surviving = decoded
|
||||
.iter()
|
||||
.filter(|replica| replica.id.pool_index != source_pool_index)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let (decoded, surviving) = decode_scanner_pause_backlog_retirement_replicas(source_pool_index, replicas)?;
|
||||
if surviving.is_empty() {
|
||||
return Err(scanner_pause_backlog_retirement_authority_conflict(
|
||||
"scanner pause backlog retirement has no surviving membership",
|
||||
@@ -1585,7 +1581,7 @@ fn plan_scanner_pause_backlog_retirement(
|
||||
}
|
||||
if decoded
|
||||
.iter()
|
||||
.filter(|replica| replica.id.pool_index == source_pool_index)
|
||||
.filter(|replica| !surviving_ids.contains(&replica.id))
|
||||
.any(|replica| {
|
||||
matches!(&replica.state, ScannerPauseBacklogReplicaState::Valid(record)
|
||||
if record.stable.as_ref() != Some(¤t.ledger)
|
||||
@@ -1723,6 +1719,12 @@ where
|
||||
.iter()
|
||||
.filter_map(|replica| replica.revision.clone().map(|revision| (replica.id, revision)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let writable_pool_indices: Arc<[usize]> = writable_ids
|
||||
.iter()
|
||||
.map(|id| id.pool_index)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
// Replica writes can share the pool namespace or the fixed multipool lock.
|
||||
// Finish each write before starting another acquisition for this publication.
|
||||
// Own the whole selected cohort so canceling the waiter cannot abandon
|
||||
@@ -1737,7 +1739,13 @@ where
|
||||
let result = match revisions.get(&id) {
|
||||
Some(revision) => storeapi
|
||||
.clone()
|
||||
.save_scanner_pause_backlog_replica(id.pool_index, id.set_index, data.clone(), revision.preconditions())
|
||||
.save_scanner_pause_backlog_replica_for_membership(
|
||||
id.pool_index,
|
||||
id.set_index,
|
||||
data.clone(),
|
||||
revision.preconditions(),
|
||||
Arc::clone(&writable_pool_indices),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.to_string()),
|
||||
None => Err("replica revision is unavailable".to_string()),
|
||||
@@ -3294,6 +3302,104 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn native_writer_stale_cohort_cannot_stabilize_survivors_after_retirement() {
|
||||
run_native_retirement_test(async || {
|
||||
let (_root, store) = native_pause_backlog_store(3, 2).await;
|
||||
let now = unix_now();
|
||||
let old = ScannerPauseBacklogController::claim(Arc::clone(&store), now)
|
||||
.await
|
||||
.expect("initial stable cohort")
|
||||
.loaded;
|
||||
let next = claim_scanner_pause_backlog_writer(&old.ledger, now + 1).expect("next native writer");
|
||||
let members = scanner_pause_backlog_replica_ids(&old.replicas);
|
||||
let committed = commit_scanner_pause_backlog_record(Some(&old.ledger), &next, &members);
|
||||
write_scanner_pause_backlog_record(Arc::clone(&store), &old, committed)
|
||||
.await
|
||||
.expect("commit before stabilization");
|
||||
let pending = load_scanner_pause_backlog(Arc::clone(&store))
|
||||
.await
|
||||
.expect("pending stabilization");
|
||||
assert_eq!(pending.ledger, next);
|
||||
assert!(!pending.stable_matches_ledger);
|
||||
let stale_topology = store.pool_meta.read().await.clone();
|
||||
let source_before = native_replica_bytes(&store.pools[0].disk_set[0]).await.0;
|
||||
store
|
||||
.prepare_scanner_pause_backlog_retirement_for_test(0, source_before.len() * 2)
|
||||
.await
|
||||
.expect("retire the old cohort's first pool");
|
||||
let surviving = [replica_id(1, 0), replica_id(1, 1), replica_id(2, 0), replica_id(2, 1)];
|
||||
let newer = claim_scanner_pause_backlog_writer(&old.ledger, now + 2).expect("surviving writer");
|
||||
replace_native_pause_backlog_replica(
|
||||
&store,
|
||||
surviving[0],
|
||||
commit_scanner_pause_backlog_record(Some(&old.ledger), &newer, &surviving),
|
||||
)
|
||||
.await;
|
||||
let mut before = Vec::new();
|
||||
for id in surviving {
|
||||
before.push(native_replica_bytes(&store.pools[id.pool_index].disk_set[id.set_index]).await);
|
||||
}
|
||||
// A node may still expose the old writable view while durable pool
|
||||
// metadata has fenced retirement. Admission must use the latter.
|
||||
*store.pool_meta.write().await = stale_topology;
|
||||
let stale = stable_scanner_pause_backlog_record(&pending.ledger, pending.authoritative_commit.as_ref(), &members);
|
||||
write_scanner_pause_backlog_record(Arc::clone(&store), &pending, stale)
|
||||
.await
|
||||
.expect_err("the old cohort must reject publication after retirement");
|
||||
for (id, expected) in surviving.into_iter().zip(before) {
|
||||
assert_eq!(
|
||||
native_replica_bytes(&store.pools[id.pool_index].disk_set[id.set_index]).await,
|
||||
expected,
|
||||
"a rejected old cohort must preserve every survivor's payload and revision"
|
||||
);
|
||||
}
|
||||
assert_eq!(native_replica_bytes(&store.pools[0].disk_set[0]).await.0, source_before);
|
||||
let invalid = Arc::clone(&store)
|
||||
.save_scanner_pause_backlog_replica_for_membership(0, 0, source_before, Default::default(), Arc::from([1, 2]))
|
||||
.await
|
||||
.expect_err("a replica outside the captured cohort must be rejected");
|
||||
assert!(matches!(invalid, EcstoreError::InvalidArgument(_, _, _)));
|
||||
shutdown_native_retirement_store(store).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn native_retirement_reads_queued_pool_authority_without_writing_it() {
|
||||
run_native_retirement_test(async || {
|
||||
let (_root, store, original) = native_expanded_backlog_store(2, false).await;
|
||||
let queued_before = native_replica_bytes(&store.pools[1].disk_set[0]).await.0;
|
||||
store
|
||||
.prepare_scanner_pause_backlog_retirement_targets_for_test(&[0, 1], queued_before.len() * 2)
|
||||
.await
|
||||
.expect("queue both old-cohort pools for retirement");
|
||||
for set_index in 0..2 {
|
||||
store
|
||||
.retire_scanner_pause_backlog_for_test(0, set_index)
|
||||
.await
|
||||
.expect("the queued pool must remain available to prove old authority");
|
||||
assert_native_source_missing(&store, set_index).await;
|
||||
for queued_set in &store.pools[1].disk_set {
|
||||
assert_eq!(
|
||||
native_replica_bytes(queued_set).await.0,
|
||||
queued_before,
|
||||
"queued authority replicas must never be rewritten"
|
||||
);
|
||||
}
|
||||
let loaded = load_scanner_pause_backlog(Arc::clone(&store))
|
||||
.await
|
||||
.expect("active survivors must own the unchanged ledger");
|
||||
assert_eq!(loaded.ledger, original);
|
||||
assert!(loaded.durable && loaded.stable_matches_ledger);
|
||||
let committed = loaded.authoritative_commit.expect("survivor commit proof");
|
||||
assert_eq!(committed.replicas, vec![replica_id(2, 0), replica_id(2, 1)]);
|
||||
}
|
||||
shutdown_native_retirement_store(store).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn native_retirement_partial_native_phases_resume_from_persisted_authority() {
|
||||
@@ -3948,10 +4054,52 @@ mod tests {
|
||||
ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: id.pool_index,
|
||||
set_index: id.set_index,
|
||||
writable: true,
|
||||
data: Some(serde_json::to_vec(record).expect("native record bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_retirement_read_only_members_cannot_hide_authority_conflicts() {
|
||||
let source = replica_id(0, 0);
|
||||
let queued = replica_id(1, 0);
|
||||
let target = replica_id(2, 0);
|
||||
let old = durable_ledger(50);
|
||||
let mut newer = old.clone();
|
||||
newer.claim_writer(100).expect("newer source authority");
|
||||
prepare_scanner_pause_backlog_persist(&mut newer, 100).expect("persist newer source authority");
|
||||
let old_record = replica_record_for_members(&old, &old, &[target]);
|
||||
let new_record = replica_record_for_members(&newer, &newer, &[source, queued]);
|
||||
let mut replicas = vec![
|
||||
retirement_replica(source, &new_record),
|
||||
retirement_replica(queued, &new_record),
|
||||
retirement_replica(target, &old_record),
|
||||
];
|
||||
replicas[1].writable = false;
|
||||
assert!(
|
||||
matches!(
|
||||
plan_scanner_pause_backlog_retirement(0, &replicas),
|
||||
Err(ScannerPauseBacklogRetirementError::AuthorityConflict { .. })
|
||||
),
|
||||
"a queued member's proof must prevent replacing surviving authority"
|
||||
);
|
||||
assert!(verify_scanner_pause_backlog_retirement(0, &replicas).is_err());
|
||||
replicas[1].data = Some(b"invalid native authority".to_vec());
|
||||
assert!(
|
||||
matches!(
|
||||
plan_scanner_pause_backlog_retirement(0, &replicas),
|
||||
Err(ScannerPauseBacklogRetirementError::InvalidRecord { .. })
|
||||
),
|
||||
"read-only members must still be decoded and validated"
|
||||
);
|
||||
replicas[1].data = None;
|
||||
replicas[2].data = None;
|
||||
assert!(
|
||||
plan_scanner_pause_backlog_retirement(0, &replicas).is_err(),
|
||||
"missing old and surviving members cannot fabricate a complete commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_retirement_requires_stabilization_and_valid_source_records() {
|
||||
let source = replica_id(0, 0);
|
||||
@@ -4033,6 +4181,7 @@ mod tests {
|
||||
replicas.extend(targets.iter().map(|id| ScannerPauseBacklogRetirementReplica {
|
||||
pool_index: id.pool_index,
|
||||
set_index: id.set_index,
|
||||
writable: true,
|
||||
data: None,
|
||||
}));
|
||||
let error = plan_scanner_pause_backlog_retirement(0, &replicas)
|
||||
|
||||
@@ -49,6 +49,7 @@ mod quota_reset_preservation;
|
||||
|
||||
mod recovery_control;
|
||||
mod scoped_ack_publication;
|
||||
mod wakeup_metrics;
|
||||
|
||||
async fn setup_scanner_cycle_store() -> (tempfile::TempDir, Arc<ECStore>) {
|
||||
setup_scanner_cycle_store_with_usage_baseline(true).await
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// 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::*;
|
||||
|
||||
const WAKEUPS: &str = "rustfs_scanner_cycle_wakeups_total";
|
||||
const ATTEMPTS: &str = "rustfs_scanner_cycle_attempts_total";
|
||||
|
||||
#[derive(Default)]
|
||||
struct TriggerRecorder(StdMutex<HashMap<(String, String), Arc<AtomicU64>>>);
|
||||
|
||||
impl metrics::Recorder for TriggerRecorder {
|
||||
fn describe_counter(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_gauge(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn describe_histogram(&self, _: metrics::KeyName, _: Option<metrics::Unit>, _: metrics::SharedString) {}
|
||||
fn register_counter(&self, key: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Counter {
|
||||
if !matches!(key.name(), WAKEUPS | ATTEMPTS) {
|
||||
return metrics::Counter::noop();
|
||||
}
|
||||
let labels = key.labels().collect::<Vec<_>>();
|
||||
assert_eq!(labels.len(), 1, "trigger cardinality is one closed reason label");
|
||||
assert_eq!(labels[0].key(), "reason");
|
||||
metrics::Counter::from_arc(
|
||||
self.0
|
||||
.lock()
|
||||
.expect("trigger counter recorder")
|
||||
.entry((key.name().to_owned(), labels[0].value().to_owned()))
|
||||
.or_default()
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
fn register_gauge(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Gauge {
|
||||
metrics::Gauge::noop()
|
||||
}
|
||||
fn register_histogram(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Histogram {
|
||||
metrics::Histogram::noop()
|
||||
}
|
||||
}
|
||||
|
||||
impl TriggerRecorder {
|
||||
fn count(&self, metric: &str, reason: &str) -> u64 {
|
||||
self.0
|
||||
.lock()
|
||||
.expect("trigger counter recorder")
|
||||
.get(&(metric.to_owned(), reason.to_owned()))
|
||||
.map_or(0, |counter| counter.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn total(&self, metric: &str) -> u64 {
|
||||
self.0
|
||||
.lock()
|
||||
.expect("trigger counter recorder")
|
||||
.iter()
|
||||
.filter(|((name, _), _)| name == metric)
|
||||
.map(|(_, counter)| counter.load(Ordering::Relaxed))
|
||||
.sum()
|
||||
}
|
||||
|
||||
async fn wait_for(&self, metric: &str, reason: &str) {
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
while self.count(metric, reason) == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the real scanner loop must emit the selected trigger");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_trigger_metrics_have_closed_reasons_and_initial_attempt_only() {
|
||||
let reasons = [
|
||||
(ScannerCycleWakeReason::Timer, "timer"),
|
||||
(ScannerCycleWakeReason::DirtyUsage, "dirty_usage"),
|
||||
(ScannerCycleWakeReason::MovementGeneration, "movement_generation"),
|
||||
(ScannerCycleWakeReason::ClusterActivity, "cluster_activity"),
|
||||
(ScannerCycleWakeReason::ClusterMaintenance, "cluster_maintenance"),
|
||||
(ScannerCycleWakeReason::ClusterActivityUnavailable, "cluster_activity_unavailable"),
|
||||
(ScannerCycleWakeReason::RuntimeConfig, "runtime_config"),
|
||||
(ScannerCycleWakeReason::MaintenanceConfig, "maintenance_config"),
|
||||
(ScannerCycleWakeReason::Recovery, "recovery"),
|
||||
(ScannerCycleWakeReason::LeaderLockLost, "leader_lock_lost"),
|
||||
(ScannerCycleWakeReason::Cancelled, "cancelled"),
|
||||
];
|
||||
let recorder = TriggerRecorder::default();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
for (reason, _) in reasons {
|
||||
record_scanner_cycle_wakeup(reason);
|
||||
record_scanner_cycle_attempt(Some(reason));
|
||||
}
|
||||
record_scanner_cycle_attempt(None);
|
||||
});
|
||||
for (_, label) in reasons {
|
||||
assert_eq!(recorder.count(WAKEUPS, label), 1);
|
||||
assert_eq!(recorder.count(ATTEMPTS, label), 1);
|
||||
}
|
||||
assert_eq!(recorder.count(WAKEUPS, "initial"), 0);
|
||||
assert_eq!(recorder.count(ATTEMPTS, "initial"), 1);
|
||||
assert_eq!(recorder.0.lock().expect("trigger counter recorder").len(), 23);
|
||||
}
|
||||
|
||||
// Tokio's default test runtime is current-thread: the recorder observes the
|
||||
// real spawned scheduler task without installing a process-global recorder.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_trigger_metrics_observe_initial_and_timer_runner_boundaries() {
|
||||
temp_env::async_with_vars([(ENV_SCANNER_CYCLE, Some("1")), (ENV_SCANNER_START_DELAY_SECS, Some("0"))], async {
|
||||
crate::runtime_config::refresh_scanner_runtime_config_for_tests();
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
global_metrics().set_cycle(None).await;
|
||||
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||
let recorder = TriggerRecorder::default();
|
||||
let _recorder = metrics::set_default_local_recorder(&recorder);
|
||||
let ctx = CancellationToken::new();
|
||||
let scanner_task = tokio::spawn(run_data_scanner(ctx.clone(), store));
|
||||
recorder.wait_for(ATTEMPTS, "initial").await;
|
||||
recorder.wait_for(ATTEMPTS, "timer").await;
|
||||
ctx.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(5), scanner_task)
|
||||
.await
|
||||
.expect("scanner loop must stop after cancellation")
|
||||
.expect("scanner task must not panic")
|
||||
.expect("scanner loop must exit cleanly");
|
||||
assert_eq!(recorder.count(ATTEMPTS, "initial"), 1);
|
||||
assert_eq!(recorder.count(WAKEUPS, "initial"), 0);
|
||||
assert_eq!(recorder.count(WAKEUPS, "timer"), recorder.count(ATTEMPTS, "timer"));
|
||||
assert_eq!(recorder.count(ATTEMPTS, "cancelled"), 0);
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
})
|
||||
.await;
|
||||
crate::runtime_config::refresh_scanner_runtime_config_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_trigger_metrics_keep_paused_startup_outside_runner_boundary() {
|
||||
temp_env::async_with_vars([(ENV_SCANNER_CYCLE, Some("1")), (ENV_SCANNER_START_DELAY_SECS, Some("0"))], async {
|
||||
crate::runtime_config::refresh_scanner_runtime_config_for_tests();
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
global_metrics().set_cycle(None).await;
|
||||
let (_temp_dir, store) = setup_scanner_cycle_store_with_pool_count(true, 2).await;
|
||||
{
|
||||
let mut pools = store.pool_meta.write().await;
|
||||
pools.pools[0].last_update = time::OffsetDateTime::now_utc();
|
||||
pools.pools[0].decommission = Some(crate::storage_api::owner::EcstorePoolDecommissionInfo {
|
||||
failed: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
assert!(store.scanner_data_movement_pause_status().await.paused);
|
||||
let recorder = TriggerRecorder::default();
|
||||
let _recorder = metrics::set_default_local_recorder(&recorder);
|
||||
let paused_probe = ScannerRuntimeObservedProbe::install(&store, true);
|
||||
let ctx = CancellationToken::new();
|
||||
let scanner_task = tokio::spawn(run_data_scanner(ctx.clone(), store));
|
||||
paused_probe.wait().await;
|
||||
drop(paused_probe);
|
||||
ctx.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(5), scanner_task)
|
||||
.await
|
||||
.expect("paused scanner must stop after cancellation")
|
||||
.expect("paused scanner task must not panic")
|
||||
.expect("paused scanner must exit cleanly");
|
||||
assert_eq!(recorder.total(ATTEMPTS), 0, "movement pause must not manufacture a runner attempt");
|
||||
assert_eq!(recorder.total(WAKEUPS), 0, "startup pause wait is not the outer cycle scheduler wait");
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
})
|
||||
.await;
|
||||
crate::runtime_config::refresh_scanner_runtime_config_for_tests();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn scanner_trigger_metrics_record_recovery_and_config_without_rate_limited_attempts() {
|
||||
temp_env::async_with_vars([(ENV_SCANNER_CYCLE, Some("1")), (ENV_SCANNER_START_DELAY_SECS, Some("0"))], async {
|
||||
crate::runtime_config::refresh_scanner_runtime_config_for_tests();
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
global_metrics().set_cycle(None).await;
|
||||
let (_temp_dir, store) = setup_scanner_cycle_store().await;
|
||||
// Model a second passing during the durable writer claim without sleeping.
|
||||
let now = scanner_pause_backlog_now().saturating_sub(1);
|
||||
let mut backlog = ScannerPauseBacklogController::claim(store.clone(), now)
|
||||
.await
|
||||
.expect("seed writer must claim the pause backlog");
|
||||
let mut observation = current_scanner_pause_backlog_observation(&store).await;
|
||||
observation.paused = true;
|
||||
observation.pause_started_at_unix_secs = now;
|
||||
backlog.observe(observation).await;
|
||||
observation.paused = false;
|
||||
backlog.observe(observation).await;
|
||||
let ScannerPauseBacklogAttemptDecision::Tracked(serial) = backlog.begin_attempt(observation.now_unix_secs).await else {
|
||||
panic!("seeded catch-up must admit its first attempt");
|
||||
};
|
||||
backlog.finish_attempt(serial, ScannerCycleOutcome::Failed, observation).await;
|
||||
drop(backlog);
|
||||
let before = scanner_pause_backlog_status(store.clone()).await;
|
||||
assert!(before.rate_limited, "the persisted catch-up gate must delay the next attempt");
|
||||
let recorder = TriggerRecorder::default();
|
||||
let _recorder = metrics::set_default_local_recorder(&recorder);
|
||||
let ctx = CancellationToken::new();
|
||||
let scanner_task = tokio::spawn(run_data_scanner(ctx.clone(), store));
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
while !scanner_cycle_schedule_status().effective_interval_available {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("rate-limited scanner must reach its normal scheduler wait");
|
||||
notify_scanner_cycle_recovery_wake();
|
||||
recorder.wait_for(WAKEUPS, "recovery").await;
|
||||
let mut config = rustfs_config::server_config::Config::new();
|
||||
config.set_defaults();
|
||||
crate::runtime_config::apply_scanner_runtime_config(&config).expect("runtime config must still apply");
|
||||
recorder.wait_for(WAKEUPS, "runtime_config").await;
|
||||
ctx.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(5), scanner_task)
|
||||
.await
|
||||
.expect("rate-limited scanner must stop after cancellation")
|
||||
.expect("rate-limited scanner task must not panic")
|
||||
.expect("rate-limited scanner must exit cleanly");
|
||||
assert_eq!(recorder.count(WAKEUPS, "recovery"), 1, "internal activity polls must not add wakeups");
|
||||
assert_eq!(recorder.count(WAKEUPS, "runtime_config"), 1);
|
||||
assert_eq!(recorder.total(ATTEMPTS), 0, "neither wakeup bypasses the existing catch-up rate limit");
|
||||
crate::scanner_io::clear_dirty_usage_buckets_for_tests();
|
||||
})
|
||||
.await;
|
||||
crate::runtime_config::refresh_scanner_runtime_config_for_tests();
|
||||
}
|
||||
@@ -1512,7 +1512,7 @@ fn scanner_results_form_complete_snapshot(results: &[DataUsageCache], expected_s
|
||||
&& scanner_results_match_scan_scope(results, expected_sources)
|
||||
}
|
||||
|
||||
fn checked_bucket_usage_info(entry: &DataUsageEntry) -> Option<BucketUsageInfo> {
|
||||
fn checked_bucket_usage_info(entry: DataUsageEntry) -> Option<BucketUsageInfo> {
|
||||
let mut usage = BucketUsageInfo {
|
||||
size: u64::try_from(entry.size).ok()?,
|
||||
versions_count: u64::try_from(entry.versions).ok()?,
|
||||
@@ -1523,12 +1523,12 @@ fn checked_bucket_usage_info(entry: &DataUsageEntry) -> Option<BucketUsageInfo>
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(replication) = &entry.replication_stats {
|
||||
if let Some(replication) = entry.replication_stats {
|
||||
usage.replica_size = replication.replica_size;
|
||||
usage.replica_count = replication.replica_count;
|
||||
for (target, stats) in &replication.targets {
|
||||
for (target, stats) in replication.targets {
|
||||
usage.replication_info.insert(
|
||||
target.clone(),
|
||||
target,
|
||||
BucketTargetUsageInfo {
|
||||
replication_pending_size: stats.pending_size,
|
||||
replicated_size: stats.replicated_size,
|
||||
|
||||
@@ -739,8 +739,8 @@ pub(super) fn completed_usage_candidate(
|
||||
|
||||
let merged_last_update = validated.last_update;
|
||||
let buckets_usage = bucket_entries
|
||||
.iter()
|
||||
.map(|(bucket, entry)| Ok((bucket.clone(), checked_bucket_usage_info(entry).ok_or(CandidateOutcome::UsageCounters)?)))
|
||||
.into_iter()
|
||||
.map(|(bucket, entry)| Ok((bucket, checked_bucket_usage_info(entry).ok_or(CandidateOutcome::UsageCounters)?)))
|
||||
.collect::<std::result::Result<HashMap<_, _>, CandidateOutcome>>()?;
|
||||
let bucket_sizes = buckets_usage
|
||||
.iter()
|
||||
@@ -1037,8 +1037,8 @@ pub(super) fn observational_data_usage_info(
|
||||
}
|
||||
let merged_last_update = merged_last_update.ok_or(CandidateOutcome::MissingLastUpdate)?;
|
||||
let buckets_usage = bucket_entries
|
||||
.iter()
|
||||
.map(|(bucket, entry)| Ok((bucket.clone(), checked_bucket_usage_info(entry).ok_or(CandidateOutcome::UsageCounters)?)))
|
||||
.into_iter()
|
||||
.map(|(bucket, entry)| Ok((bucket, checked_bucket_usage_info(entry).ok_or(CandidateOutcome::UsageCounters)?)))
|
||||
.collect::<std::result::Result<HashMap<_, _>, CandidateOutcome>>()?;
|
||||
Ok((
|
||||
DataUsageInfo {
|
||||
|
||||
@@ -996,6 +996,160 @@ fn completed_data_usage_info_adds_same_bucket_across_unique_sets() {
|
||||
assert_eq!(bucket.delete_markers_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_bucket_usage_info_moves_replication_keys_and_preserves_the_snapshot() {
|
||||
let first_target = "arn:rustfs:replication:local::target-a".to_string();
|
||||
let zero_target = "arn:rustfs:replication:local::zero-counters".to_string();
|
||||
let mut entry = DataUsageEntry {
|
||||
size: 32768,
|
||||
objects: 9,
|
||||
versions: 14,
|
||||
delete_markers: 2,
|
||||
replication_stats: Some(ReplicationAllStats {
|
||||
replica_size: 777,
|
||||
replica_count: 3,
|
||||
targets: HashMap::from([
|
||||
(
|
||||
first_target.clone(),
|
||||
ReplicationTargetUsage {
|
||||
pending_size: 101,
|
||||
replicated_size: 102,
|
||||
failed_size: 103,
|
||||
failed_count: 104,
|
||||
pending_count: 105,
|
||||
missed_threshold_size: 106,
|
||||
after_threshold_size: 107,
|
||||
missed_threshold_count: 108,
|
||||
after_threshold_count: 109,
|
||||
replicated_count: 110,
|
||||
},
|
||||
),
|
||||
(zero_target.clone(), ReplicationTargetUsage::default()),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
entry.obj_sizes.add(2048);
|
||||
entry.obj_sizes.add(32768);
|
||||
entry.obj_versions.add(0);
|
||||
entry.obj_versions.add(3);
|
||||
let target_allocations = entry
|
||||
.replication_stats
|
||||
.as_ref()
|
||||
.expect("replication fixture")
|
||||
.targets
|
||||
.keys()
|
||||
.map(|target| (target.clone(), target.as_ptr()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let usage = checked_bucket_usage_info(entry).expect("owned usage entry remains representable");
|
||||
for (target, allocation) in target_allocations {
|
||||
assert_eq!(
|
||||
usage
|
||||
.replication_info
|
||||
.get_key_value(&target)
|
||||
.expect("retained replication target")
|
||||
.0
|
||||
.as_ptr(),
|
||||
allocation,
|
||||
"the target key must move from the consumed entry without cloning its allocation"
|
||||
);
|
||||
}
|
||||
let expected = BucketUsageInfo {
|
||||
size: 32768,
|
||||
replication_pending_size_v1: 0,
|
||||
replication_failed_size_v1: 0,
|
||||
replicated_size_v1: 0,
|
||||
replication_pending_count_v1: 0,
|
||||
replication_failed_count_v1: 0,
|
||||
objects_count: 9,
|
||||
object_size_histogram: HashMap::from([
|
||||
("LESS_THAN_1024_B".to_string(), 0),
|
||||
("BETWEEN_1024_B_AND_64_KB".to_string(), 2),
|
||||
("BETWEEN_64_KB_AND_256_KB".to_string(), 0),
|
||||
("BETWEEN_256_KB_AND_512_KB".to_string(), 0),
|
||||
("BETWEEN_512_KB_AND_1_MB".to_string(), 0),
|
||||
("BETWEEN_1024B_AND_1_MB".to_string(), 2),
|
||||
("BETWEEN_1_MB_AND_10_MB".to_string(), 0),
|
||||
("BETWEEN_10_MB_AND_64_MB".to_string(), 0),
|
||||
("BETWEEN_64_MB_AND_128_MB".to_string(), 0),
|
||||
("BETWEEN_128_MB_AND_512_MB".to_string(), 0),
|
||||
("GREATER_THAN_512_MB".to_string(), 0),
|
||||
]),
|
||||
object_versions_histogram: HashMap::from([
|
||||
("UNVERSIONED".to_string(), 1),
|
||||
("SINGLE_VERSION".to_string(), 0),
|
||||
("BETWEEN_2_AND_10".to_string(), 1),
|
||||
("BETWEEN_10_AND_100".to_string(), 0),
|
||||
("BETWEEN_100_AND_1000".to_string(), 0),
|
||||
("BETWEEN_1000_AND_10000".to_string(), 0),
|
||||
("GREATER_THAN_10000".to_string(), 0),
|
||||
]),
|
||||
versions_count: 14,
|
||||
delete_markers_count: 2,
|
||||
replica_size: 777,
|
||||
replica_count: 3,
|
||||
replication_info: HashMap::from([
|
||||
(
|
||||
first_target,
|
||||
BucketTargetUsageInfo {
|
||||
replication_pending_size: 101,
|
||||
replication_failed_size: 103,
|
||||
replicated_size: 102,
|
||||
replica_size: 0,
|
||||
replication_pending_count: 105,
|
||||
replication_failed_count: 104,
|
||||
replicated_count: 110,
|
||||
},
|
||||
),
|
||||
(zero_target, BucketTargetUsageInfo::default()),
|
||||
]),
|
||||
};
|
||||
assert_eq!(usage, expected);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&usage).expect("serialize owned usage"),
|
||||
serde_json::to_value(&expected).expect("serialize expected snapshot")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_bucket_usage_info_keeps_missing_empty_and_zero_replication_values() {
|
||||
for replication_stats in [
|
||||
None,
|
||||
Some(ReplicationAllStats::default()),
|
||||
Some(ReplicationAllStats {
|
||||
targets: HashMap::from([(String::new(), ReplicationTargetUsage::default())]),
|
||||
..Default::default()
|
||||
}),
|
||||
] {
|
||||
let has_empty_target = replication_stats.as_ref().is_some_and(|stats| !stats.targets.is_empty());
|
||||
let usage = checked_bucket_usage_info(DataUsageEntry {
|
||||
replication_stats,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("zero usage remains representable");
|
||||
assert_eq!(usage.object_size_histogram.len(), 11);
|
||||
assert!(usage.object_size_histogram.values().all(|value| *value == 0));
|
||||
assert_eq!(usage.object_versions_histogram.len(), 7);
|
||||
assert!(usage.object_versions_histogram.values().all(|value| *value == 0));
|
||||
let expected = BucketUsageInfo {
|
||||
object_size_histogram: usage.object_size_histogram.clone(),
|
||||
object_versions_histogram: usage.object_versions_histogram.clone(),
|
||||
replication_info: if has_empty_target {
|
||||
HashMap::from([(String::new(), BucketTargetUsageInfo::default())])
|
||||
} else {
|
||||
HashMap::new()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(usage, expected);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&usage).expect("serialize zero usage"),
|
||||
serde_json::to_value(&expected).expect("serialize expected zero snapshot")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_data_usage_info_flattens_nested_bucket_entries() {
|
||||
let all_buckets = vec!["bucket".to_string()];
|
||||
@@ -1030,7 +1184,8 @@ fn completed_data_usage_info_flattens_nested_bucket_entries() {
|
||||
first_set.replace("bucket/prefix", "bucket", nested);
|
||||
let second_set = completed_root_cache("bucket", 3, 20, DataUsageCacheSource::new(1, 0));
|
||||
|
||||
let (data_usage_info, _) = completed_data_usage_info_for_test(&[first_set, second_set], &all_buckets, false, false)
|
||||
let mut results = [first_set, second_set];
|
||||
let (data_usage_info, _) = completed_data_usage_info_for_test(&results, &all_buckets, false, false)
|
||||
.expect("nested bucket entries should be flattened before aggregation");
|
||||
let bucket = data_usage_info.buckets_usage.get("bucket").expect("merged bucket usage");
|
||||
|
||||
@@ -1044,6 +1199,30 @@ fn completed_data_usage_info_flattens_nested_bucket_entries() {
|
||||
assert_eq!(bucket.replica_count, 2);
|
||||
assert_eq!(bucket.replication_info["arn:target"].replicated_size, 2048);
|
||||
assert_eq!(bucket.replication_info["arn:target"].replicated_count, 2);
|
||||
|
||||
let sources = results.iter().filter_map(|result| result.info.source).collect::<HashSet<_>>();
|
||||
let (observed, _) = observational_data_usage_info(&results, &sources, &all_buckets, &[], TEST_PLAN_DIGEST, 0, 0)
|
||||
.expect("current snapshots retain the same bucket usage on the observational path");
|
||||
assert_eq!(observed.buckets_usage, data_usage_info.buckets_usage);
|
||||
assert_eq!(observed.bucket_sizes, data_usage_info.bucket_sizes);
|
||||
assert!(!observed.usage_snapshot_complete);
|
||||
assert_eq!(observed.usage_snapshot_converged, Some(false));
|
||||
|
||||
for result in &mut results {
|
||||
result.info.snapshot_complete = false;
|
||||
result.info.lkg_snapshot_complete = true;
|
||||
result.info.lkg_next_cycle = Some(0);
|
||||
result.info.lkg_leader_epoch = Some(0);
|
||||
result.info.lkg_last_update = result.info.last_update;
|
||||
result.info.lkg_scan_plan_digest = Some(TEST_PLAN_DIGEST);
|
||||
}
|
||||
let (observed_lkg, _) = observational_data_usage_info(&results, &sources, &all_buckets, &[], TEST_PLAN_DIGEST, 1, 1)
|
||||
.expect("LKG snapshots retain the same bucket usage after leader change");
|
||||
assert_eq!(observed_lkg.buckets_usage, data_usage_info.buckets_usage);
|
||||
assert_eq!(observed_lkg.bucket_sizes, data_usage_info.bucket_sizes);
|
||||
assert!(!observed_lkg.usage_snapshot_complete);
|
||||
assert_eq!(observed_lkg.usage_snapshot_converged, Some(false));
|
||||
assert!(observed_lkg.usage_snapshot_set_states.iter().all(|state| !state.complete));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -410,12 +410,13 @@ pub(crate) trait ScannerStorage:
|
||||
async fn list_bucket_for_scanner(&self, opts: &storage_contracts::BucketOptions) -> EcstoreResultType<ScannerBucketListing>;
|
||||
fn all_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
||||
async fn scanner_pause_backlog_writable_set_disks(&self) -> Vec<Arc<EcstoreSetDisks>>;
|
||||
async fn save_scanner_pause_backlog_replica(
|
||||
async fn save_scanner_pause_backlog_replica_for_membership(
|
||||
self: Arc<Self>,
|
||||
pool_index: usize,
|
||||
set_index: usize,
|
||||
data: Vec<u8>,
|
||||
preconditions: storage_contracts::HTTPPreconditions,
|
||||
writable_pool_indices: Arc<[usize]>,
|
||||
) -> EcstoreResultType<()>;
|
||||
#[cfg(test)]
|
||||
fn scanner_observed_probe_store_key(&self) -> usize;
|
||||
@@ -483,16 +484,24 @@ impl ScannerStorage for EcstoreStore {
|
||||
EcstoreStore::scanner_pause_backlog_writable_set_disks(self).await
|
||||
}
|
||||
|
||||
async fn save_scanner_pause_backlog_replica(
|
||||
async fn save_scanner_pause_backlog_replica_for_membership(
|
||||
self: Arc<Self>,
|
||||
pool_index: usize,
|
||||
set_index: usize,
|
||||
data: Vec<u8>,
|
||||
preconditions: storage_contracts::HTTPPreconditions,
|
||||
writable_pool_indices: Arc<[usize]>,
|
||||
) -> EcstoreResultType<()> {
|
||||
EcstoreStore::save_scanner_pause_backlog_replica(&self, pool_index, set_index, data, preconditions)
|
||||
.await
|
||||
.map(|_| ())
|
||||
EcstoreStore::save_scanner_pause_backlog_replica_for_membership(
|
||||
&self,
|
||||
pool_index,
|
||||
set_index,
|
||||
data,
|
||||
preconditions,
|
||||
writable_pool_indices,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -659,12 +668,13 @@ mod tests {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn save_scanner_pause_backlog_replica(
|
||||
async fn save_scanner_pause_backlog_replica_for_membership(
|
||||
self: Arc<Self>,
|
||||
_pool_index: usize,
|
||||
_set_index: usize,
|
||||
_data: Vec<u8>,
|
||||
_preconditions: storage_contracts::HTTPPreconditions,
|
||||
_writable_pool_indices: Arc<[usize]>,
|
||||
) -> EcstoreResultType<()> {
|
||||
Err(EcstoreErrorType::InvalidArgument(
|
||||
"scanner-backlog".into(),
|
||||
|
||||
@@ -68,6 +68,7 @@ proptest = "1"
|
||||
[[bench]]
|
||||
name = "hash_hotpath_benchmark"
|
||||
harness = false
|
||||
required-features = ["hash", "http", "path"]
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true, optional = true, features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
|
||||
use rustfs_utils::HashAlgorithm;
|
||||
use rustfs_utils::http::{
|
||||
SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_CRC, SUFFIX_INLINE_DATA, SUFFIX_PART_CHECKSUMS, SUFFIX_TRANSITION_STATUS,
|
||||
SUFFIX_TRANSITIONED_VERSION_ID, get_consistent_str, has_internal_suffix, insert_str, is_internal_key,
|
||||
strip_internal_prefix_preserving_case,
|
||||
};
|
||||
use rustfs_utils::path::{clean, path_join_buf};
|
||||
use std::collections::HashMap;
|
||||
use std::hint::black_box;
|
||||
|
||||
fn generate_payload(size: usize) -> Vec<u8> {
|
||||
@@ -51,5 +58,104 @@ fn bench_hash_hotpaths(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_hash_hotpaths);
|
||||
fn bench_metadata_compat_hotpaths(c: &mut Criterion) {
|
||||
let keys = [
|
||||
"content-type",
|
||||
"x-amz-meta-author",
|
||||
"x-amz-meta-project",
|
||||
"x-amz-meta-env",
|
||||
"x-rustfs-internal-compression",
|
||||
"x-minio-internal-compression",
|
||||
"x-rustfs-internal-compression-size",
|
||||
"x-minio-internal-compression-size",
|
||||
"x-rustfs-internal-actual-size",
|
||||
"x-minio-internal-actual-size",
|
||||
"x-rustfs-internal-crc",
|
||||
"x-minio-internal-crc",
|
||||
"x-rustfs-internal-inline-data",
|
||||
"x-minio-internal-inline-data",
|
||||
"X-Minio-Internal-Transition-Status",
|
||||
"X-RustFS-Internal-Transitioned-VersionID",
|
||||
];
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert("content-type".to_string(), "application/octet-stream".to_string());
|
||||
map.insert("x-amz-meta-author".to_string(), "test-user".to_string());
|
||||
map.insert("x-amz-meta-project".to_string(), "rustfs".to_string());
|
||||
map.insert("x-amz-meta-env".to_string(), "production".to_string());
|
||||
insert_str(&mut map, SUFFIX_COMPRESSION, "klauspost/compress/s2".to_string());
|
||||
insert_str(&mut map, SUFFIX_ACTUAL_SIZE, "2097152".to_string());
|
||||
insert_str(&mut map, SUFFIX_CRC, "crc32c:abcdef01".to_string());
|
||||
insert_str(&mut map, SUFFIX_INLINE_DATA, "true".to_string());
|
||||
insert_str(&mut map, SUFFIX_TRANSITION_STATUS, "COMPLETE".to_string());
|
||||
|
||||
let mut group = c.benchmark_group("metadata_compat_hotpath");
|
||||
group.throughput(Throughput::Elements(u64::try_from(keys.len()).expect("benchmark key count fits in u64")));
|
||||
group.bench_function("classify_and_strip_batch", |b| {
|
||||
b.iter(|| {
|
||||
let mut matched = 0usize;
|
||||
for &key in black_box(&keys) {
|
||||
if is_internal_key(key) {
|
||||
matched += 1;
|
||||
}
|
||||
if let Some(suffix) = strip_internal_prefix_preserving_case(key) {
|
||||
matched += suffix.len();
|
||||
}
|
||||
if has_internal_suffix(key, SUFFIX_CRC)
|
||||
|| has_internal_suffix(key, SUFFIX_PART_CHECKSUMS)
|
||||
|| has_internal_suffix(key, SUFFIX_TRANSITIONED_VERSION_ID)
|
||||
{
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
black_box(matched)
|
||||
});
|
||||
});
|
||||
|
||||
group.throughput(Throughput::Elements(3));
|
||||
group.bench_function("get_consistent_str_lookup", |b| {
|
||||
b.iter(|| {
|
||||
let a = get_consistent_str(black_box(&map), black_box(SUFFIX_CRC));
|
||||
let b_val = get_consistent_str(black_box(&map), black_box(SUFFIX_TRANSITION_STATUS));
|
||||
let c_val = get_consistent_str(black_box(&map), black_box(SUFFIX_PART_CHECKSUMS));
|
||||
black_box((a, b_val, c_val))
|
||||
});
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_path_hotpaths(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("path_hotpath");
|
||||
let object_elems = [
|
||||
"/data/rustfs0",
|
||||
"bucket-production",
|
||||
"2026/10/22/prefix",
|
||||
"object-00001234",
|
||||
"xl.meta",
|
||||
];
|
||||
let dir_elems = [
|
||||
"/data/rustfs0/",
|
||||
"bucket-production",
|
||||
"2026/10/22/prefix/",
|
||||
"subdir-00001234/",
|
||||
];
|
||||
let clean_input = "/data/rustfs0/bucket-production/2026/10/22/prefix/object-00001234/xl.meta";
|
||||
let dirty_input = "/data/rustfs0//bucket-production/./2026/10/22/prefix/sub/../object-00001234/xl.meta/";
|
||||
|
||||
group.bench_function("path_join_buf_object", |b| {
|
||||
b.iter(|| black_box(path_join_buf(black_box(&object_elems))));
|
||||
});
|
||||
group.bench_function("path_join_buf_dir_trailing_slash", |b| {
|
||||
b.iter(|| black_box(path_join_buf(black_box(&dir_elems))));
|
||||
});
|
||||
group.bench_function("clean_already_clean", |b| {
|
||||
b.iter(|| black_box(clean(black_box(clean_input))));
|
||||
});
|
||||
group.bench_function("clean_needs_clean", |b| {
|
||||
b.iter(|| black_box(clean(black_box(dirty_input))));
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_hash_hotpaths, bench_metadata_compat_hotpaths, bench_path_hotpaths);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -116,11 +116,143 @@ pub const SUFFIX_ODM_SOURCE_VERSION_ID: &str = "odm-source-version-id";
|
||||
/// When the object was pulled from the source, RFC 3339.
|
||||
pub const SUFFIX_ODM_PULLED_AT: &str = "odm-pulled-at";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AsciiCaseMaskU64 {
|
||||
mask: u64,
|
||||
lower: u64,
|
||||
}
|
||||
|
||||
impl AsciiCaseMaskU64 {
|
||||
const fn new(target: &[u8; 8]) -> Self {
|
||||
let mut mask = [0u8; 8];
|
||||
let mut lower = [0u8; 8];
|
||||
let mut i = 0;
|
||||
while i < 8 {
|
||||
if target[i].is_ascii_alphabetic() {
|
||||
mask[i] = 0x20;
|
||||
lower[i] = target[i] | 0x20;
|
||||
} else {
|
||||
lower[i] = target[i];
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Self {
|
||||
mask: u64::from_ne_bytes(mask),
|
||||
lower: u64::from_ne_bytes(lower),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
const fn matches(self, word: u64) -> bool {
|
||||
(word | self.mask) == self.lower
|
||||
}
|
||||
}
|
||||
|
||||
const RUSTFS_PREFIX_W0: AsciiCaseMaskU64 = AsciiCaseMaskU64::new(b"x-rustfs");
|
||||
const RUSTFS_PREFIX_W1: AsciiCaseMaskU64 = AsciiCaseMaskU64::new(b"-interna");
|
||||
const MINIO_PREFIX_W0: AsciiCaseMaskU64 = AsciiCaseMaskU64::new(b"x-minio-");
|
||||
const MINIO_PREFIX_W1: AsciiCaseMaskU64 = AsciiCaseMaskU64::new(b"internal");
|
||||
|
||||
#[inline(always)]
|
||||
fn read_u64_ne(bytes: &[u8], offset: usize) -> u64 {
|
||||
let mut buf = [0u8; 8];
|
||||
buf.copy_from_slice(&bytes[offset..offset + 8]);
|
||||
u64::from_ne_bytes(buf)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn read_u32_ne(bytes: &[u8], offset: usize) -> u32 {
|
||||
let mut buf = [0u8; 4];
|
||||
buf.copy_from_slice(&bytes[offset..offset + 4]);
|
||||
u32::from_ne_bytes(buf)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn starts_with_rustfs_internal_prefix(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= RUSTFS_INTERNAL_PREFIX.len()
|
||||
&& bytes[17] == b'-'
|
||||
&& (bytes[16] | 0x20) == b'l'
|
||||
&& RUSTFS_PREFIX_W0.matches(read_u64_ne(bytes, 0))
|
||||
&& RUSTFS_PREFIX_W1.matches(read_u64_ne(bytes, 8))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn starts_with_minio_internal_prefix(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= MINIO_INTERNAL_PREFIX.len()
|
||||
&& bytes[16] == b'-'
|
||||
&& MINIO_PREFIX_W0.matches(read_u64_ne(bytes, 0))
|
||||
&& MINIO_PREFIX_W1.matches(read_u64_ne(bytes, 8))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
const fn eq_u64_ignore_ascii_case(wa: u64, wb: u64) -> bool {
|
||||
let diff = wa ^ wb;
|
||||
if diff == 0 {
|
||||
return true;
|
||||
}
|
||||
if (diff & !0x2020_2020_2020_2020) != 0 {
|
||||
return false;
|
||||
}
|
||||
let l = (wa | 0x2020_2020_2020_2020) & 0x7f7f_7f7f_7f7f_7f7f;
|
||||
let alpha_20 =
|
||||
(((l.wrapping_add(0x1f1f_1f1f_1f1f_1f1f)) ^ (l.wrapping_add(0x0505_0505_0505_0505))) & !wa & 0x8080_8080_8080_8080) >> 2;
|
||||
(diff & alpha_20) == diff
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
const fn eq_u32_ignore_ascii_case(wa: u32, wb: u32) -> bool {
|
||||
let diff = wa ^ wb;
|
||||
if diff == 0 {
|
||||
return true;
|
||||
}
|
||||
if (diff & !0x2020_2020) != 0 {
|
||||
return false;
|
||||
}
|
||||
let l = (wa | 0x2020_2020) & 0x7f7f_7f7f;
|
||||
let alpha_20 = (((l.wrapping_add(0x1f1f_1f1f)) ^ (l.wrapping_add(0x0505_0505))) & !wa & 0x8080_8080) >> 2;
|
||||
(diff & alpha_20) == diff
|
||||
}
|
||||
|
||||
/// SWAR-accelerated ASCII case-insensitive byte-slice equality.
|
||||
/// Compares 8 bytes at a time using 64-bit word operations instead of scalar per-byte loops.
|
||||
#[inline]
|
||||
pub fn eq_slice_ignore_ascii_case(a: &[u8], b: &[u8]) -> bool {
|
||||
let len = a.len();
|
||||
if len != b.len() {
|
||||
return false;
|
||||
}
|
||||
if len >= 8 {
|
||||
if !eq_u64_ignore_ascii_case(read_u64_ne(a, 0), read_u64_ne(b, 0)) {
|
||||
return false;
|
||||
}
|
||||
let mut i = 8;
|
||||
while i + 8 < len {
|
||||
if !eq_u64_ignore_ascii_case(read_u64_ne(a, i), read_u64_ne(b, i)) {
|
||||
return false;
|
||||
}
|
||||
i += 8;
|
||||
}
|
||||
return len == 8 || eq_u64_ignore_ascii_case(read_u64_ne(a, len - 8), read_u64_ne(b, len - 8));
|
||||
}
|
||||
if len >= 4 {
|
||||
return eq_u32_ignore_ascii_case(read_u32_ne(a, 0), read_u32_ne(b, 0))
|
||||
&& (len == 4 || eq_u32_ignore_ascii_case(read_u32_ne(a, len - 4), read_u32_ne(b, len - 4)));
|
||||
}
|
||||
match (a, b) {
|
||||
([], []) => true,
|
||||
([a0], [b0]) => a0.eq_ignore_ascii_case(b0),
|
||||
([a0, a1], [b0, b1]) => a0.eq_ignore_ascii_case(b0) && a1.eq_ignore_ascii_case(b1),
|
||||
([a0, a1, a2], [b0, b1, b2]) => a0.eq_ignore_ascii_case(b0) && a1.eq_ignore_ascii_case(b1) && a2.eq_ignore_ascii_case(b2),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive (ASCII) check that `s` begins with `prefix`. Equivalent to
|
||||
/// `s.to_lowercase().starts_with(prefix)` when `prefix` is ASCII (as both internal prefixes are),
|
||||
/// but without allocating.
|
||||
#[inline]
|
||||
pub fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool {
|
||||
s.len() >= prefix.len() && s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
|
||||
s.len() >= prefix.len() && eq_slice_ignore_ascii_case(&s.as_bytes()[..prefix.len()], prefix.as_bytes())
|
||||
}
|
||||
|
||||
/// Allocation-free equivalent of `key.to_lowercase() == format!("{prefix}{suffix}")`.
|
||||
@@ -136,35 +268,32 @@ fn internal_key_eq(key: &str, prefix: &str, suffix: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
let (key_prefix, key_suffix) = key.split_at(prefix.len());
|
||||
key_prefix.eq_ignore_ascii_case(prefix.as_bytes())
|
||||
&& key_suffix
|
||||
.iter()
|
||||
.zip(suffix.as_bytes())
|
||||
.all(|(k, s)| k.to_ascii_lowercase() == *s)
|
||||
eq_slice_ignore_ascii_case(key_prefix, prefix.as_bytes())
|
||||
&& eq_slice_ignore_ascii_case(key_suffix, suffix.as_bytes())
|
||||
&& !suffix.as_bytes().iter().any(u8::is_ascii_uppercase)
|
||||
}
|
||||
|
||||
/// Returns true if the key is an internal metadata key (x-rustfs-internal-* or x-minio-internal-*)
|
||||
/// for xl.meta compatibility. Case-insensitive.
|
||||
#[inline]
|
||||
pub fn is_internal_key(key: &str) -> bool {
|
||||
starts_with_ignore_ascii_case(key, RUSTFS_INTERNAL_PREFIX) || starts_with_ignore_ascii_case(key, MINIO_INTERNAL_PREFIX)
|
||||
let bytes = key.as_bytes();
|
||||
starts_with_rustfs_internal_prefix(bytes) || starts_with_minio_internal_prefix(bytes)
|
||||
}
|
||||
|
||||
/// Returns true if the key matches the given suffix for either x-rustfs-internal-* or x-minio-internal-*.
|
||||
#[inline]
|
||||
pub fn has_internal_suffix(key: &str, suffix: &str) -> bool {
|
||||
internal_key_eq(key, RUSTFS_INTERNAL_PREFIX, suffix) || internal_key_eq(key, MINIO_INTERNAL_PREFIX, suffix)
|
||||
if !key.is_ascii() {
|
||||
return internal_key_eq(key, RUSTFS_INTERNAL_PREFIX, suffix) || internal_key_eq(key, MINIO_INTERNAL_PREFIX, suffix);
|
||||
}
|
||||
matches_either_internal_key_ignore_ascii_case(key, suffix) && !suffix.as_bytes().iter().any(u8::is_ascii_uppercase)
|
||||
}
|
||||
|
||||
/// Strips x-rustfs-internal- or x-minio-internal- prefix from key. Returns the suffix part.
|
||||
/// Case-insensitive. Returns None if key is not an internal key.
|
||||
pub fn strip_internal_prefix(key: &str) -> Option<String> {
|
||||
let rest = if starts_with_ignore_ascii_case(key, RUSTFS_INTERNAL_PREFIX) {
|
||||
&key[RUSTFS_INTERNAL_PREFIX.len()..]
|
||||
} else if starts_with_ignore_ascii_case(key, MINIO_INTERNAL_PREFIX) {
|
||||
&key[MINIO_INTERNAL_PREFIX.len()..]
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(rest.to_lowercase())
|
||||
strip_internal_prefix_preserving_case(key).map(str::to_lowercase)
|
||||
}
|
||||
|
||||
/// Returns true if key is internal and its suffix part starts with the given suffix_prefix.
|
||||
@@ -177,9 +306,9 @@ pub fn internal_key_starts_with(key: &str, suffix_prefix: &str) -> bool {
|
||||
/// returning the remainder (e.g. "arn1"). Returns None if key does not match.
|
||||
pub fn internal_key_strip_suffix_prefix(key: &str, suffix_prefix: &str) -> Option<String> {
|
||||
let rest = strip_internal_prefix_preserving_case(key)?;
|
||||
rest.get(..suffix_prefix.len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(suffix_prefix))
|
||||
.then(|| rest[suffix_prefix.len()..].to_string())
|
||||
(rest.len() >= suffix_prefix.len()
|
||||
&& eq_slice_ignore_ascii_case(&rest.as_bytes()[..suffix_prefix.len()], suffix_prefix.as_bytes()))
|
||||
.then(|| rest[suffix_prefix.len()..].to_string())
|
||||
}
|
||||
|
||||
fn both_keys(suffix: &str) -> (String, String) {
|
||||
@@ -223,17 +352,18 @@ pub fn insert_str(map: &mut HashMap<String, String>, suffix: &str, value: String
|
||||
map.insert(k2, value);
|
||||
}
|
||||
|
||||
fn eq_prefix_suffix_ignore_ascii_case(key: &str, prefix: &str, suffix: &str) -> bool {
|
||||
if key.len() != prefix.len() + suffix.len() {
|
||||
return false;
|
||||
}
|
||||
let (key_prefix, key_suffix) = key.as_bytes().split_at(prefix.len());
|
||||
key_prefix.eq_ignore_ascii_case(prefix.as_bytes()) && key_suffix.eq_ignore_ascii_case(suffix.as_bytes())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn matches_either_internal_key_ignore_ascii_case(key: &str, suffix: &str) -> bool {
|
||||
eq_prefix_suffix_ignore_ascii_case(key, RUSTFS_INTERNAL_PREFIX, suffix)
|
||||
|| eq_prefix_suffix_ignore_ascii_case(key, MINIO_INTERNAL_PREFIX, suffix)
|
||||
let key_bytes = key.as_bytes();
|
||||
let suffix_bytes = suffix.as_bytes();
|
||||
let prefix_matched = if key_bytes.len() == RUSTFS_INTERNAL_PREFIX.len() + suffix_bytes.len() {
|
||||
starts_with_rustfs_internal_prefix(key_bytes)
|
||||
} else if key_bytes.len() == MINIO_INTERNAL_PREFIX.len() + suffix_bytes.len() {
|
||||
starts_with_minio_internal_prefix(key_bytes)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
prefix_matched && eq_slice_ignore_ascii_case(&key_bytes[key_bytes.len() - suffix_bytes.len()..], suffix_bytes)
|
||||
}
|
||||
|
||||
pub fn get_str(map: &HashMap<String, String>, suffix: &str) -> Option<String> {
|
||||
@@ -317,11 +447,15 @@ pub fn remove_bytes(map: &mut HashMap<String, Vec<u8>>, suffix: &str) {
|
||||
}
|
||||
|
||||
/// Strips an internal metadata prefix while preserving the suffix casing.
|
||||
#[inline]
|
||||
pub fn strip_internal_prefix_preserving_case(key: &str) -> Option<&str> {
|
||||
if starts_with_ignore_ascii_case(key, RUSTFS_INTERNAL_PREFIX) {
|
||||
key.get(RUSTFS_INTERNAL_PREFIX.len()..)
|
||||
} else if starts_with_ignore_ascii_case(key, MINIO_INTERNAL_PREFIX) {
|
||||
key.get(MINIO_INTERNAL_PREFIX.len()..)
|
||||
let bytes = key.as_bytes();
|
||||
if starts_with_rustfs_internal_prefix(bytes) {
|
||||
// The matched prefix is 18 ASCII bytes, so index 18 is always a UTF-8 char boundary.
|
||||
Some(&key[RUSTFS_INTERNAL_PREFIX.len()..])
|
||||
} else if starts_with_minio_internal_prefix(bytes) {
|
||||
// The matched prefix is 17 ASCII bytes, so index 17 is always a UTF-8 char boundary.
|
||||
Some(&key[MINIO_INTERNAL_PREFIX.len()..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -354,7 +488,7 @@ fn internal_versions_by_arn(map: &HashMap<String, String>, arn_prefix: &str) ->
|
||||
let Some(prefix) = suffix.get(..arn_prefix.len()) else {
|
||||
continue;
|
||||
};
|
||||
if !prefix.eq_ignore_ascii_case(arn_prefix) {
|
||||
if !eq_slice_ignore_ascii_case(prefix.as_bytes(), arn_prefix.as_bytes()) {
|
||||
continue;
|
||||
}
|
||||
let arn = &suffix[arn_prefix.len()..];
|
||||
@@ -723,6 +857,49 @@ mod tests {
|
||||
assert!(contains_key_str(&metadata, SUFFIX_ODM_SOURCE_ETAG));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_eq_slice_ignore_ascii_case_swar_exhaustive_and_boundaries() {
|
||||
for ab in 0u8..=u8::MAX {
|
||||
for bb in 0u8..=u8::MAX {
|
||||
let expected = ab.eq_ignore_ascii_case(&bb);
|
||||
let mut s8_a = [b'x'; 8];
|
||||
let mut s8_b = [b'X'; 8];
|
||||
s8_a[3] = ab;
|
||||
s8_b[3] = bb;
|
||||
assert_eq!(
|
||||
eq_slice_ignore_ascii_case(&s8_a, &s8_b),
|
||||
expected,
|
||||
"u64 SWAR mismatch for ({ab:#04x}, {bb:#04x})"
|
||||
);
|
||||
|
||||
let mut s4_a = [b'a'; 4];
|
||||
let mut s4_b = [b'A'; 4];
|
||||
s4_a[2] = ab;
|
||||
s4_b[2] = bb;
|
||||
assert_eq!(
|
||||
eq_slice_ignore_ascii_case(&s4_a, &s4_b),
|
||||
expected,
|
||||
"u32 SWAR mismatch for ({ab:#04x}, {bb:#04x})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for len in 0usize..=35 {
|
||||
let lower: Vec<u8> = (0..len)
|
||||
.map(|i| b'a' + u8::try_from(i % 26).expect("modulo fits in u8"))
|
||||
.collect();
|
||||
let upper: Vec<u8> = lower.iter().map(u8::to_ascii_uppercase).collect();
|
||||
assert!(eq_slice_ignore_ascii_case(&lower, &upper));
|
||||
for pos in 0..len {
|
||||
let mut mutated = upper.clone();
|
||||
mutated[pos] = b'@';
|
||||
let mut other = lower.clone();
|
||||
other[pos] = b'`';
|
||||
assert!(!eq_slice_ignore_ascii_case(&mutated, &other), "len={len} pos={pos}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delete_marker_versions_preserve_arn_case_and_report_conflicts() {
|
||||
let arn = "arn:rustfs:replication::Target:Bucket";
|
||||
|
||||
+213
-65
@@ -135,16 +135,22 @@ pub fn path_join<P: AsRef<Path>>(elem: &[P]) -> PathBuf {
|
||||
|
||||
for e in elem {
|
||||
let s = e.as_ref().to_string_lossy();
|
||||
if added > 0 || !s.is_empty() {
|
||||
if added > 0 {
|
||||
dst.push_str(SLASH_SEPARATOR);
|
||||
}
|
||||
dst.push_str(&s);
|
||||
added += s.len();
|
||||
if s.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if added > 0 && (cfg!(target_os = "windows") || !(dst.ends_with(SLASH_SEPARATOR) ^ s.starts_with(SLASH_SEPARATOR))) {
|
||||
dst.push_str(SLASH_SEPARATOR);
|
||||
}
|
||||
dst.push_str(&s);
|
||||
added += s.len();
|
||||
}
|
||||
|
||||
if path_needs_clean(dst.as_bytes()) {
|
||||
let check_str = match dst.strip_suffix(SLASH_SEPARATOR) {
|
||||
Some(trimmed) if !cfg!(target_os = "windows") && trailing_slash && !trimmed.is_empty() => trimmed,
|
||||
_ => dst.as_str(),
|
||||
};
|
||||
|
||||
if path_needs_clean(check_str.as_bytes()) {
|
||||
let mut clean_path = clean(&dst);
|
||||
if trailing_slash {
|
||||
clean_path.push_str(SLASH_SEPARATOR);
|
||||
@@ -152,7 +158,7 @@ pub fn path_join<P: AsRef<Path>>(elem: &[P]) -> PathBuf {
|
||||
return clean_path.into();
|
||||
}
|
||||
|
||||
if trailing_slash {
|
||||
if trailing_slash && (dst.len() <= 1 || !dst.ends_with(SLASH_SEPARATOR)) {
|
||||
dst.push_str(SLASH_SEPARATOR);
|
||||
}
|
||||
|
||||
@@ -175,17 +181,23 @@ pub fn path_join_buf(elements: &[&str]) -> String {
|
||||
let mut dst = String::with_capacity(len);
|
||||
let mut added = 0;
|
||||
|
||||
for e in elements {
|
||||
if added > 0 || !e.is_empty() {
|
||||
if added > 0 {
|
||||
dst.push_str(SLASH_SEPARATOR);
|
||||
}
|
||||
dst.push_str(e);
|
||||
added += e.len();
|
||||
for &e in elements {
|
||||
if e.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if added > 0 && (cfg!(target_os = "windows") || !(dst.ends_with(SLASH_SEPARATOR) ^ e.starts_with(SLASH_SEPARATOR))) {
|
||||
dst.push_str(SLASH_SEPARATOR);
|
||||
}
|
||||
dst.push_str(e);
|
||||
added += e.len();
|
||||
}
|
||||
|
||||
if path_needs_clean(dst.as_bytes()) {
|
||||
let check_str = match dst.strip_suffix(SLASH_SEPARATOR) {
|
||||
Some(trimmed) if !cfg!(target_os = "windows") && trailing_slash && !trimmed.is_empty() => trimmed,
|
||||
_ => dst.as_str(),
|
||||
};
|
||||
|
||||
if path_needs_clean(check_str.as_bytes()) {
|
||||
let mut clean_path = clean(&dst);
|
||||
if trailing_slash {
|
||||
clean_path.push_str(SLASH_SEPARATOR);
|
||||
@@ -193,7 +205,7 @@ pub fn path_join_buf(elements: &[&str]) -> String {
|
||||
return clean_path;
|
||||
}
|
||||
|
||||
if trailing_slash {
|
||||
if trailing_slash && (dst.len() <= 1 || !dst.ends_with(SLASH_SEPARATOR)) {
|
||||
dst.push_str(SLASH_SEPARATOR);
|
||||
}
|
||||
|
||||
@@ -223,6 +235,50 @@ pub fn fold_object_name_path_separators(object: &str, backslash_is_separator: bo
|
||||
Cow::Owned(segments.join(SLASH_SEPARATOR))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
const fn swar_dot_or_slash_mask(w: u64) -> u64 {
|
||||
let y = (w | 0x0101_0101_0101_0101) ^ 0x5050_5050_5050_5050;
|
||||
y.wrapping_add(0x0101_0101_0101_0101) & 0x8080_8080_8080_8080
|
||||
}
|
||||
|
||||
/// SWAR fast-path detector for already-clean ASCII paths (length >= 8).
|
||||
///
|
||||
/// Precondition: caller (`path_needs_clean`) has already rejected Windows backslashes (`\\`),
|
||||
/// leading `.`, and trailing `/`.
|
||||
///
|
||||
/// Checks 8 bytes at a time via `slice::as_chunks::<8>()` in little-endian byte order that no
|
||||
/// byte has the high bit set (`> 127`) and no two adjacent bytes are both in `{'.', '/'}`
|
||||
/// (which covers `"//"`, `"/."`, `"./"`, and `".."`).
|
||||
/// Both `'.'` (`0x2e`) and `'/'` (`0x2f`) satisfy `(b | 0x01) == 0x2f`, and `0x2f ^ 0x50 == 0x7f`;
|
||||
/// adding `0x01` sets bit 7 (`0x80`) iff an ASCII byte is `'.'` or `'/'`, with zero cross-byte carry.
|
||||
#[inline(always)]
|
||||
const fn swar_is_clean_ascii_path(path: &[u8]) -> bool {
|
||||
let (chunks, rem) = path.as_chunks::<8>();
|
||||
if chunks.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut i = 0;
|
||||
let mut prev_hi = 0u64;
|
||||
while i < chunks.len() {
|
||||
let w = u64::from_le_bytes(chunks[i]);
|
||||
let m = swar_dot_or_slash_mask(w);
|
||||
if ((w & 0x8080_8080_8080_8080) | (m & ((m << 8) | prev_hi))) != 0 {
|
||||
return false;
|
||||
}
|
||||
prev_hi = m >> 56;
|
||||
i += 1;
|
||||
}
|
||||
if rem.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some(&tail) = path.last_chunk::<8>() else {
|
||||
return false;
|
||||
};
|
||||
let w = u64::from_le_bytes(tail);
|
||||
let m = swar_dot_or_slash_mask(w);
|
||||
((w & 0x8080_8080_8080_8080) | (m & (m << 8))) == 0
|
||||
}
|
||||
|
||||
/// path_needs_clean returns whether path cleaning may change the path.
|
||||
/// Will detect all cases that will be cleaned,
|
||||
/// but may produce false positives on non-trivial paths.
|
||||
@@ -236,10 +292,18 @@ fn path_needs_clean(path: &[u8]) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
let rooted = path[0] == b'/';
|
||||
let n = path.len();
|
||||
if let [first, .., last] = path
|
||||
&& *first != b'.'
|
||||
&& *first <= 127
|
||||
&& *last != b'/'
|
||||
&& swar_is_clean_ascii_path(path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let (mut r, mut w) = if rooted { (1, 1) } else { (0, 0) };
|
||||
let n = path.len();
|
||||
let rooted = path[0] == b'/';
|
||||
let mut r = if rooted { 1 } else { 0 };
|
||||
|
||||
while r < n {
|
||||
match path[r] {
|
||||
@@ -260,42 +324,24 @@ fn path_needs_clean(path: &[u8]) -> bool {
|
||||
// .. element: remove to last / - assume it has to be cleaned.
|
||||
return true;
|
||||
}
|
||||
// Handle single dot case
|
||||
if r + 1 == n {
|
||||
// . element - assume it has to be cleaned.
|
||||
return true;
|
||||
}
|
||||
// Copy the dot
|
||||
w += 1;
|
||||
r += 1;
|
||||
}
|
||||
_ => {
|
||||
// real path element.
|
||||
// add slash if needed
|
||||
if (rooted && w != 1) || (!rooted && w != 0) {
|
||||
w += 1;
|
||||
}
|
||||
// copy element
|
||||
while r < n && path[r] != b'/' {
|
||||
if cfg!(target_os = "windows") && path[r] == b'\\' {
|
||||
return true;
|
||||
// Advance to next slash (backslashes on Windows were already rejected above).
|
||||
if let Some(pos) = path[r..].iter().position(|&b| b == b'/') {
|
||||
r += pos;
|
||||
// allow one slash, not at end
|
||||
if r < n - 1 {
|
||||
r += 1;
|
||||
}
|
||||
w += 1;
|
||||
r += 1;
|
||||
}
|
||||
// allow one slash, not at end
|
||||
if r < n - 1 && path[r] == b'/' {
|
||||
r += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Turn empty string into "."
|
||||
if w == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -454,6 +500,76 @@ impl LazyBuf {
|
||||
}
|
||||
}
|
||||
|
||||
struct SliceLazyBuf<'a> {
|
||||
s: &'a str,
|
||||
buf: Option<Vec<u8>>,
|
||||
w: usize,
|
||||
}
|
||||
|
||||
impl<'a> SliceLazyBuf<'a> {
|
||||
#[inline]
|
||||
fn new(s: &'a str) -> Self {
|
||||
Self { s, buf: None, w: 0 }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn index(&self, i: usize) -> u8 {
|
||||
if let Some(ref buf) = self.buf {
|
||||
buf[i]
|
||||
} else {
|
||||
self.s.as_bytes()[i]
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn ensure_buf(&mut self) -> &mut Vec<u8> {
|
||||
let s_bytes = self.s.as_bytes();
|
||||
let w = self.w;
|
||||
self.buf.get_or_insert_with(|| {
|
||||
let mut new_buf = vec![0; s_bytes.len()];
|
||||
new_buf[..w].copy_from_slice(&s_bytes[..w]);
|
||||
new_buf
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn append(&mut self, c: u8) {
|
||||
if self.buf.is_none() && self.w < self.s.len() && self.s.as_bytes()[self.w] == c {
|
||||
self.w += 1;
|
||||
return;
|
||||
}
|
||||
let w = self.w;
|
||||
let buf = self.ensure_buf();
|
||||
buf[w] = c;
|
||||
self.w = w + 1;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn append_range(&mut self, start: usize, end: usize) {
|
||||
let len = end - start;
|
||||
if self.buf.is_none() && self.w == start {
|
||||
self.w += len;
|
||||
return;
|
||||
}
|
||||
let s_bytes = self.s.as_bytes();
|
||||
let w = self.w;
|
||||
let buf = self.ensure_buf();
|
||||
buf[w..w + len].copy_from_slice(&s_bytes[start..end]);
|
||||
self.w = w + len;
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn into_string(self) -> String {
|
||||
if let Some(mut buf) = self.buf {
|
||||
buf.truncate(self.w);
|
||||
// Input `self.s` is valid UTF-8 and separator/dot replacements are ASCII boundaries.
|
||||
String::from_utf8(buf).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
|
||||
} else {
|
||||
self.s[..self.w].to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the shortest path name equivalent to the given path.
|
||||
///
|
||||
/// It applies the following rules iteratively until no further processing can be done:
|
||||
@@ -477,9 +593,10 @@ pub fn clean(path: &str) -> String {
|
||||
return ".".to_string();
|
||||
}
|
||||
|
||||
let rooted = path.starts_with('/') || (cfg!(target_os = "windows") && path.starts_with('\\'));
|
||||
let n = path.len();
|
||||
let mut out = LazyBuf::new(path.to_string());
|
||||
let bytes = path.as_bytes();
|
||||
let rooted = is_separator(bytes[0]);
|
||||
let n = bytes.len();
|
||||
let mut out = SliceLazyBuf::new(path);
|
||||
let mut r = 0;
|
||||
let mut dotdot = 0;
|
||||
|
||||
@@ -490,18 +607,15 @@ pub fn clean(path: &str) -> String {
|
||||
}
|
||||
|
||||
while r < n {
|
||||
let c = path.as_bytes()[r];
|
||||
let c = bytes[r];
|
||||
|
||||
if is_separator(c) {
|
||||
// Empty path element
|
||||
r += 1;
|
||||
} else if c == b'.' && (r + 1 == n || is_separator(path.as_bytes()[r + 1])) {
|
||||
} else if c == b'.' && (r + 1 == n || is_separator(bytes[r + 1])) {
|
||||
// . element
|
||||
r += 1;
|
||||
} else if c == b'.'
|
||||
&& (r + 1 < n && path.as_bytes()[r + 1] == b'.')
|
||||
&& (r + 2 == n || is_separator(path.as_bytes()[r + 2]))
|
||||
{
|
||||
} else if c == b'.' && (r + 1 < n && bytes[r + 1] == b'.') && (r + 2 == n || is_separator(bytes[r + 2])) {
|
||||
// .. element: remove to last /
|
||||
r += 2;
|
||||
|
||||
@@ -527,15 +641,10 @@ pub fn clean(path: &str) -> String {
|
||||
out.append(b'/');
|
||||
}
|
||||
|
||||
// Copy element
|
||||
while r < n {
|
||||
let c = path.as_bytes()[r];
|
||||
if is_separator(c) {
|
||||
break;
|
||||
}
|
||||
out.append(c);
|
||||
r += 1;
|
||||
}
|
||||
// Copy element slice in bulk
|
||||
let seg_len = bytes[r..].iter().position(|&b| is_separator(b)).unwrap_or(n - r);
|
||||
out.append_range(r, r + seg_len);
|
||||
r += seg_len;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +653,7 @@ pub fn clean(path: &str) -> String {
|
||||
return ".".to_string();
|
||||
}
|
||||
|
||||
out.string()
|
||||
out.into_string()
|
||||
}
|
||||
|
||||
/// Splits path immediately following the final separator, separating it into a directory and file name component.
|
||||
@@ -689,6 +798,45 @@ mod tests {
|
||||
assert_eq!(trim_etag("\"098f6bcd4621d373cade4e832627b4f6-2\""), "098f6bcd4621d373cade4e832627b4f6-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_swar_path_needs_clean_and_join_boundaries() {
|
||||
for len in 8..=34 {
|
||||
let clean_bytes: Vec<u8> = (0..len)
|
||||
.map(|i| if i % 4 == 1 && i + 1 < len { b'/' } else { b'a' })
|
||||
.collect();
|
||||
assert!(!path_needs_clean(&clean_bytes), "clean path failed for len={len}");
|
||||
|
||||
for pos in 0..(len - 1) {
|
||||
let mut doubled = vec![b'a'; len];
|
||||
doubled[pos] = b'/';
|
||||
doubled[pos + 1] = b'/';
|
||||
assert!(path_needs_clean(&doubled), "missed '//' at len={len}, pos={pos}");
|
||||
|
||||
let mut dot_seg = vec![b'a'; len];
|
||||
dot_seg[pos] = b'/';
|
||||
dot_seg[pos + 1] = b'.';
|
||||
if pos + 2 < len {
|
||||
dot_seg[pos + 2] = b'/';
|
||||
}
|
||||
assert!(path_needs_clean(&dot_seg), "missed '/.' at len={len}, pos={pos}");
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!path_needs_clean(b"/data/rustfs0/bucket/.hidden/xl.meta"));
|
||||
assert!(!path_needs_clean(b"/data/rustfs0/bucket/..hidden/xl.meta"));
|
||||
assert!(path_needs_clean(b"/data/rustfs0/bucket/./xl.meta"));
|
||||
assert!(path_needs_clean(b"/data/rustfs0/bucket/../xl.meta"));
|
||||
|
||||
assert_eq!(
|
||||
path_join_buf(&["/data/rustfs0/", "bucket", "prefix/", "subdir/"]),
|
||||
"/data/rustfs0/bucket/prefix/subdir/"
|
||||
);
|
||||
assert_eq!(
|
||||
path_join_buf(&["/data/rustfs0/", "/bucket", "prefix/", "xl.meta"]),
|
||||
"/data/rustfs0/bucket/prefix/xl.meta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_dir_from_prefix() {
|
||||
let a = "da/";
|
||||
|
||||
@@ -113,6 +113,10 @@ Under continuous writes, a structurally complete walk can finish as `superseded`
|
||||
|
||||
Superseded convergence retries retain exponential backoff for usage-only work. Active lifecycle or replication rules, and uncertain maintenance inspection, cap that retry interval at the ordinary configured cadence so complete observations cannot defer regular maintenance. An explicit cycle remains the duty-cycle floor. Convergence mode still suppresses dirty/activity shortcuts and does not grant quota authority; deferred backend/proof retries and pause catch-up limits retain their existing scheduling. Cycle runtime, pacing, and disk admission can still delay completed maintenance. Sustained supersedes with active maintenance may therefore run more full scans than usage-only convergence; use the existing pacing controls to manage that background I/O.
|
||||
|
||||
The telemetry recorder exports `rustfs_scanner_cycle_wakeups_total{reason}` for the reason actually selected when the outer scheduler wait returns. It does not count every notification or the internal activity-probe timer slices. Reasons are `timer`, `dirty_usage`, `movement_generation`, `cluster_activity`, `cluster_maintenance`, `cluster_activity_unavailable`, `runtime_config`, `maintenance_config`, `recovery`, `leader_lock_lost`, and `cancelled`.
|
||||
|
||||
`rustfs_scanner_cycle_attempts_total{reason}` counts arrivals at the existing budget-fenced cycle-runner call boundary, after the scheduler's pause, catch-up rate-limit, and leader checks. The startup attempt uses `reason="initial"` and has no matching wakeup. Configuration changes, cancellation, leader loss, or a blocked pause/catch-up gate can produce a wakeup without an attempt. An attempt may still be preempted inside the runner by cancellation, a fence, or a deadline; it does not prove an actual walk, completed cycle, healthy service, or idle convergence. Both families are process-local counters emitted through the existing telemetry recorder; without an installed recorder they are no-ops. No new setting or background probe is introduced.
|
||||
|
||||
## Single-disk clean-idle scheduling
|
||||
|
||||
An erasure single-disk deployment using the built-in cycle and bitrot defaults automatically backs off repeated clean idle scans instead of walking the same unchanged namespace every minute. Each successful timer-driven cycle that finds no dirty usage or unresolved maintenance work doubles the next interval. The status endpoint reports the effective interval and multiplier.
|
||||
|
||||
@@ -40,6 +40,8 @@ Forced tier removal skips constructing the remote driver and probing the remote
|
||||
|
||||
The dedicated free-version recovery loop is enabled by default and is independent of the data scanner and heal switches. Setting `RUSTFS_SCANNER_ENABLED=false` does not stop this repair loop. Set `RUSTFS_TIER_FREE_VERSION_RECOVERY_ENABLED=false` before process startup to disable only the dedicated persisted-marker walk. That setting does not disable lifecycle workers or prevent another scanner path from discovering a free version, and it can leave remote cleanup markers pending for longer, so use it as a break-glass pressure control rather than a cleanup mechanism.
|
||||
|
||||
Before each recovery page, the leader checks its current tier configuration. If no remote tiers are configured, it skips bucket listing and object walks, clears its in-memory continuation markers, and uses the normal idle backoff to check again. The worker stays running when configuration has not yet loaded or becomes empty. Once a tier is visible in the leader's configuration, discovery resumes from the beginning at the next check; the idle interval is capped at ten minutes. A configuration change after a page starts does not cancel that page. This pause retains persisted free-version markers and does not change explicit recovery calls, other ILM recovery workers, or the fences on already queued cleanup tasks.
|
||||
|
||||
Enabled nodes compete for the independent `tier-free-version-recovery/leader.lock` namespace lock in the internal metadata bucket. The leader holds the renewable lock across pages, cooldowns, retries, and idle waits; followers only attempt ownership with a one-second acquisition budget and a random 15–30 second delay between attempts. This uses the existing pool 0 locker majority, so recovery pauses when that lock domain cannot form quorum even if another pool remains available. Recovery resumes when ownership can be acquired again. It does not depend on the main scanner's leader lock or enable switch.
|
||||
|
||||
Loss of the leader lease or process shutdown cancels discovery and waits for active walks to stop before the coordinator exits that leadership term. A successor starts a new sweep from the beginning because continuation markers remain process-local; frequent leadership changes can therefore delay discovery near the end of a large namespace. Already queued cleanup tasks retain their existing exact-version and object-lock checks and may finish after leadership changes. During a rolling upgrade, older nodes still perform independent walks until they are upgraded or recovery is disabled on them.
|
||||
|
||||
@@ -64,6 +64,8 @@ use super::storage_api::bucket_usecase::{
|
||||
get_validated_store, process_lambda_configurations, process_queue_configurations, process_topic_configurations,
|
||||
request_context, validate_list_object_unordered_with_delimiter,
|
||||
};
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
use super::storage_api::diagnostic;
|
||||
use crate::app::bucket_list_through as list_through;
|
||||
use crate::app::object::shared::mark_on_demand_migration_list_local_only;
|
||||
use crate::app::object_data_cache::invalidate_object_data_cache_bucket_after_delete;
|
||||
@@ -1409,16 +1411,20 @@ where
|
||||
MakeFuture: FnOnce(OwnedSemaphorePermit) -> F + Send + 'static,
|
||||
{
|
||||
let (mutation_permit, in_flight) = admission.acquire(operation).await?;
|
||||
let api_operation = rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context();
|
||||
tokio::spawn(
|
||||
rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(api_operation, async move {
|
||||
let _in_flight = in_flight;
|
||||
make_future(mutation_permit).await
|
||||
})
|
||||
.in_current_span(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{operation} task failed: {err}")))?
|
||||
let future = async move {
|
||||
let _in_flight = in_flight;
|
||||
make_future(mutation_permit).await
|
||||
};
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let future = diagnostic::with_captured_read_version_context(diagnostic::ReadVersionContextBoundary::BucketTask, future);
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
let future = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(
|
||||
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context(),
|
||||
future,
|
||||
);
|
||||
tokio::spawn(future.in_current_span())
|
||||
.await
|
||||
.map_err(|err| S3Error::with_message(S3ErrorCode::InternalError, format!("{operation} task failed: {err}")))?
|
||||
}
|
||||
|
||||
impl DefaultBucketUsecase {
|
||||
@@ -3413,6 +3419,73 @@ mod tests {
|
||||
assert_eq!(*second_bad.code(), S3ErrorCode::MalformedXML);
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[tokio::test]
|
||||
async fn diagnostic_bucket_usecase_task_keeps_entry_and_permits_after_waiter_drop() {
|
||||
use super::diagnostic::{ReadVersionEntrySource, current_read_version_entry_source, with_read_version_entry};
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_context, current_s3_http_operation_name, observe_s3_http_operation,
|
||||
with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
let mut request = S3HttpRequestGuard::new("PUT");
|
||||
let operation = request.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::CreateBucket);
|
||||
current_s3_http_operation_context().expect("captured bucket operation")
|
||||
});
|
||||
let admission = Arc::new(BucketOperationAdmission::new(1, 1));
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
|
||||
let mut caller = Box::pin(with_s3_http_operation_context(Some(operation), async {
|
||||
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
|
||||
await_bucket_usecase_on_fresh_task_with_admission(
|
||||
"test bucket operation",
|
||||
admission.clone(),
|
||||
|permit| async move {
|
||||
entered_tx.send(()).expect("caller is present");
|
||||
release_rx.await.expect("bucket work may finish");
|
||||
let observed = with_read_version_entry(ReadVersionEntrySource::ConfigRead, async {
|
||||
(current_s3_http_operation_name(), current_read_version_entry_source())
|
||||
})
|
||||
.await;
|
||||
drop(permit);
|
||||
finished_tx.send(observed).expect("completion observer is present");
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}));
|
||||
assert!(futures::poll!(caller.as_mut()).is_pending());
|
||||
entered_rx.await.expect("bucket task entered");
|
||||
drop(caller);
|
||||
drop(request);
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
assert_eq!(admission.active.available_permits(), 0);
|
||||
assert_eq!(admission.in_flight.available_permits(), 1);
|
||||
release_tx.send(()).expect("detached bucket task remains alive");
|
||||
assert_eq!(
|
||||
finished_rx.await.expect("bucket task completes"),
|
||||
(Some("s3:CreateBucket"), Some(ReadVersionEntrySource::TierConfigReload))
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while admission.in_flight.available_permits() != 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("completed task releases admission");
|
||||
assert_eq!(admission.active.available_permits(), 1);
|
||||
let internal = await_bucket_usecase_on_fresh_task_with_admission("internal test", admission, |_permit| async {
|
||||
Ok((current_s3_http_operation_name(), current_read_version_entry_source()))
|
||||
})
|
||||
.await
|
||||
.expect("unscoped bucket task completes");
|
||||
assert_eq!(internal, (None, None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bucket_usecase_task_preserves_http_operation_context() {
|
||||
use rustfs_io_metrics::s3_http_metrics::{S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation};
|
||||
|
||||
@@ -828,8 +828,6 @@ impl DefaultObjectUsecase {
|
||||
.await;
|
||||
|
||||
let _manager = get_concurrency_manager();
|
||||
let _bucket_clone = bucket.clone();
|
||||
let _deleted_objects = dobjs.clone();
|
||||
if !errs.is_empty() && errs.iter().all(|err| err.as_ref().is_some_and(is_err_bucket_not_found)) {
|
||||
let result = Err(S3Error::with_message(S3ErrorCode::NoSuchBucket, "Bucket not found".to_string()));
|
||||
let _ = helper.complete(&result);
|
||||
|
||||
@@ -750,9 +750,16 @@ mod tests {
|
||||
assert_eq!(raw_object_bytes(&store, &bucket, "race").await, b"client");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
async fn write_back_preserves_delete_markers_unless_policy_allows_revival() {
|
||||
fn write_back_preserves_delete_markers_unless_policy_allows_revival() {
|
||||
crate::app::gating_test_env::run_large_stack_test(
|
||||
"odm-write-back-delete-marker-revival",
|
||||
write_back_preserves_delete_markers_unless_policy_allows_revival_inner,
|
||||
);
|
||||
}
|
||||
|
||||
async fn write_back_preserves_delete_markers_unless_policy_allows_revival_inner() {
|
||||
for multipart in [false, true] {
|
||||
let (store, bucket) = write_back_test_bucket("odm-wb-tombstone", true).await;
|
||||
let write_back = OnDemandMigrationWriteBack::new();
|
||||
|
||||
@@ -26,10 +26,12 @@ pub(crate) fn EndpointServerPools(
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub(crate) mod diagnostic {
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::ecstore_diagnostic::current_read_version_entry_source;
|
||||
pub(crate) use crate::storage::storage_api::ecstore_diagnostic::{
|
||||
ReadVersionContextBoundary, ReadVersionEntrySource, with_read_version_spawn_context,
|
||||
ReadVersionContextBoundary, ReadVersionEntrySource, with_captured_read_version_context, with_read_version_spawn_context,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::storage::storage_api::ecstore_diagnostic::{
|
||||
current_read_version_entry_source, with_read_version_entry,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,14 @@ fn mimalloc_stat_current(value: &Value, metric: &str) -> Option<u64> {
|
||||
.and_then(numeric_json_value)
|
||||
}
|
||||
|
||||
#[cfg(any(not(target_os = "windows"), test))]
|
||||
fn mimalloc_stat_total(value: &Value, metric: &str) -> Option<u64> {
|
||||
match find_mimalloc_stat(value, metric)? {
|
||||
Value::Object(fields) => fields.get("total").and_then(numeric_json_value),
|
||||
value => numeric_json_value(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(not(target_os = "windows"), test))]
|
||||
fn parse_mimalloc_stats_json(stats_json: &str) -> Option<AllocatorMemoryObservation> {
|
||||
let value = serde_json::from_str::<Value>(stats_json).ok()?;
|
||||
@@ -279,6 +287,14 @@ fn parse_mimalloc_stats_json(stats_json: &str) -> Option<AllocatorMemoryObservat
|
||||
committed_bytes: mimalloc_stat_current(&value, "committed"),
|
||||
page_committed_bytes: mimalloc_stat_current(&value, "page_committed"),
|
||||
heap_count: mimalloc_stat_current(&value, "heaps").or_else(|| mimalloc_stat_current(&value, "heap_count")),
|
||||
// Mimalloc statistics schema 6 emits scalar totals; older schemas can use an
|
||||
// object with a total. Neither representation reports live memory.
|
||||
reset_bytes_total: mimalloc_stat_total(&value, "reset"),
|
||||
purged_bytes_total: mimalloc_stat_total(&value, "purged"),
|
||||
commit_calls_total: mimalloc_stat_total(&value, "commit_calls"),
|
||||
reset_calls_total: mimalloc_stat_total(&value, "reset_calls"),
|
||||
purge_calls_total: mimalloc_stat_total(&value, "purge_calls"),
|
||||
arena_purge_passes_total: mimalloc_stat_total(&value, "arena_purges"),
|
||||
..AllocatorMemoryObservation::default()
|
||||
};
|
||||
if let Some(requested) = find_mimalloc_stat(&value, "malloc_requested") {
|
||||
@@ -632,6 +648,110 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn reclaim_stat_totals(observation: rustfs_io_metrics::AllocatorMemoryObservation) -> [Option<u64>; 6] {
|
||||
[
|
||||
observation.reset_bytes_total,
|
||||
observation.purged_bytes_total,
|
||||
observation.commit_calls_total,
|
||||
observation.reset_calls_total,
|
||||
observation.purge_calls_total,
|
||||
observation.arena_purge_passes_total,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mimalloc_stats_json_reclaim_totals_match_schema_six() {
|
||||
// Mimalloc 3.5.4 (rustfs-mimalloc 0.7.0) emits count objects and
|
||||
// scalar counter totals in the same JSON document.
|
||||
let parsed = parse_mimalloc_stats_json(
|
||||
r#"{
|
||||
"stat_version": 6,
|
||||
"mimalloc_version": 30504,
|
||||
"process": { "rss_current": 16384 },
|
||||
"reserved": { "total": 65536, "peak": 32768, "current": 16384 },
|
||||
"committed": { "total": 32768, "peak": 16384, "current": 8192 },
|
||||
"reset": 4096,
|
||||
"purged": 8192,
|
||||
"commit_calls": 3,
|
||||
"reset_calls": 4,
|
||||
"purge_calls": 5,
|
||||
"arena_purges": 6
|
||||
}"#,
|
||||
)
|
||||
.expect("schema-six counter totals should parse");
|
||||
assert_eq!(reclaim_stat_totals(parsed), [Some(4096), Some(8192), Some(3), Some(4), Some(5), Some(6)]);
|
||||
assert_eq!(parsed.reserved_bytes, Some(16384));
|
||||
assert_eq!(parsed.committed_bytes, Some(8192));
|
||||
assert_eq!(parsed.malloc_requested_bytes, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mimalloc_stats_json_reclaim_totals_keep_legacy_total_semantics() {
|
||||
let parsed = parse_mimalloc_stats_json(
|
||||
r#"{ "wrapped": {
|
||||
"reset": { "total": 0, "current": 999 },
|
||||
"purged": { "total": "8192", "current": 999 },
|
||||
"commit_calls": { "total": 3, "current": 999 },
|
||||
"reset_calls": { "total": 4, "current": 999 },
|
||||
"purge_calls": { "total": 5, "current": 999 },
|
||||
"arena_purges": { "total": 6, "current": 999 }
|
||||
} }"#,
|
||||
)
|
||||
.expect("legacy total objects should parse");
|
||||
assert_eq!(reclaim_stat_totals(parsed), [Some(0), Some(8192), Some(3), Some(4), Some(5), Some(6)]);
|
||||
assert_eq!(parsed.reserved_bytes, None);
|
||||
assert_eq!(parsed.committed_bytes, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mimalloc_stats_json_reclaim_zero_does_not_imply_live_memory_availability() {
|
||||
let parsed = parse_mimalloc_stats_json(
|
||||
r#"{
|
||||
"reset": 0, "purged": "0", "commit_calls": 0,
|
||||
"reset_calls": 0, "purge_calls": 0, "arena_purges": 0,
|
||||
"malloc_requested": { "current": 0, "peak": 0, "total": 0 }
|
||||
}"#,
|
||||
)
|
||||
.expect("present zero totals should not disappear");
|
||||
assert_eq!(reclaim_stat_totals(parsed), [Some(0); 6]);
|
||||
assert_eq!(parsed.reserved_bytes, None);
|
||||
assert_eq!(parsed.committed_bytes, None);
|
||||
assert_eq!(parsed.malloc_requested_bytes, None);
|
||||
assert_eq!(parsed.malloc_requested_peak_bytes, None);
|
||||
assert_eq!(parsed.malloc_requested_total_bytes, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mimalloc_stats_json_reclaim_totals_preserve_missing_and_invalid() {
|
||||
for invalid in [
|
||||
"null",
|
||||
"-1",
|
||||
"1.5",
|
||||
"true",
|
||||
"\"invalid\"",
|
||||
"[]",
|
||||
r#"{ "current": 123 }"#,
|
||||
r#"{ "total": -1, "current": 123 }"#,
|
||||
r#"{ "total": "18446744073709551616" }"#,
|
||||
] {
|
||||
let payload = format!(
|
||||
r#"{{
|
||||
"reserved": {{ "current": 1 }},
|
||||
"reset": {invalid}, "purged": {invalid}, "commit_calls": {invalid},
|
||||
"reset_calls": {invalid}, "purge_calls": {invalid}, "arena_purges": {invalid}
|
||||
}}"#
|
||||
);
|
||||
let parsed = parse_mimalloc_stats_json(&payload).expect("reserved stat should remain available");
|
||||
assert_eq!(reclaim_stat_totals(parsed), [None; 6], "invalid counter: {invalid}");
|
||||
assert_eq!(parsed.reserved_bytes, Some(1));
|
||||
}
|
||||
let missing = parse_mimalloc_stats_json(r#"{ "reserved": { "current": 1 } }"#)
|
||||
.expect("reserved stat should parse without counters");
|
||||
assert_eq!(reclaim_stat_totals(missing), [None; 6]);
|
||||
let duplicate = parse_mimalloc_stats_json(r#"{ "reset": { "current": 7 }, "wrapped": { "reset": 99 } }"#);
|
||||
assert_eq!(duplicate, None, "a duplicate must not supply a total missing from the selected statistic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mimalloc_stats_json_rejects_unrecognized_payload() {
|
||||
assert_eq!(parse_mimalloc_stats_json(r#"{ "allocator": "unknown" }"#), None);
|
||||
@@ -656,7 +776,15 @@ mod tests {
|
||||
fn read_allocator_memory_snapshot_uses_mimalloc_stats_json() {
|
||||
let snapshot = super::read_allocator_memory_snapshot();
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert!(snapshot.is_some(), "allocator snapshot should be available on non-Windows");
|
||||
{
|
||||
let snapshot = snapshot.expect("allocator snapshot should be available on non-Windows");
|
||||
assert!(
|
||||
reclaim_stat_totals(snapshot.observation)
|
||||
.into_iter()
|
||||
.all(|value| value.is_some()),
|
||||
"the active mimalloc schema should expose all six cumulative statistics",
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
assert!(snapshot.is_none(), "allocator snapshot should be absent without mimalloc");
|
||||
}
|
||||
|
||||
+105
-1
@@ -292,7 +292,9 @@ where
|
||||
|
||||
/// Start accounting for an external S3 request. While a typed telemetry trace
|
||||
/// is being recorded, the finished request is also published to the trace bus
|
||||
/// as a pre-classified event; otherwise no clock is read.
|
||||
/// as a pre-classified event. The separate metrics-gated entry-to-dispatch
|
||||
/// timer measures wall time through authentication, parsing and scheduling.
|
||||
/// Neither timer reads a clock when its own gate is disabled.
|
||||
pub fn s3_http_request_guard(method: &str) -> S3HttpRequestGuard {
|
||||
let guard = S3HttpRequestGuard::new(method);
|
||||
if telemetry_trace_subscriber_count() == 0 {
|
||||
@@ -1267,6 +1269,108 @@ pub(in crate::server) mod tests {
|
||||
PublicHealthEndpointLayer::new(crate::runtime_sources::ServerContextSlot::new(), readiness)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_s3_http_entry_to_dispatch_metrics() {
|
||||
const CHILD_ENV: &str = "RUSTFS_TEST_HTTP_DISPATCH_METRICS_PROCESS";
|
||||
if std::env::var_os(CHILD_ENV).is_none() {
|
||||
// The metric gate is process-wide. Match the console-prefix test
|
||||
// pattern to isolate this switch from other server unit tests.
|
||||
let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
|
||||
.args([
|
||||
"server::layer::tests::external_s3_http_entry_to_dispatch_metrics",
|
||||
"--exact",
|
||||
"--test-threads=1",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.output()
|
||||
.expect("run isolated dispatch timing test");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("1 passed; 0 failed"),
|
||||
"isolated dispatch timing test must execute exactly once: {stdout}{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
|
||||
use rustfs_io_metrics::record_s3_op;
|
||||
use rustfs_s3_ops::S3Operation;
|
||||
|
||||
let previous_gate = rustfs_io_metrics::metrics_enabled();
|
||||
let recorder = DebuggingRecorder::new();
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
rustfs_io_metrics::set_metrics_enabled(true);
|
||||
let inner = tower::service_fn(|req: Request<()>| async move {
|
||||
match req.uri().path() {
|
||||
"/bucket/rejected" => Ok(Response::builder().status(403).body(()).expect("rejected response")),
|
||||
"/bucket/service-error" => Err(io::Error::other("pre-dispatch service failure")),
|
||||
"/bucket/cancelled" => std::future::pending::<Result<Response<()>, io::Error>>().await,
|
||||
_ => {
|
||||
record_s3_op(S3Operation::DeleteObjects);
|
||||
Ok(Response::new(()))
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut service = ExternalRequestContextLayer::default().layer(inner);
|
||||
let mut cx = Context::from_waker(std::task::Waker::noop());
|
||||
for path in [
|
||||
"/bucket/dispatched",
|
||||
"/bucket/rejected",
|
||||
"/health/ready",
|
||||
"/rustfs/admin/v3/info",
|
||||
] {
|
||||
let mut response = Box::pin(service.call(Request::builder().uri(path).body(()).expect("request")));
|
||||
assert!(matches!(response.as_mut().poll(&mut cx), Poll::Ready(Ok(_))), "{path}");
|
||||
}
|
||||
let mut failed = Box::pin(
|
||||
service.call(
|
||||
Request::builder()
|
||||
.uri("/bucket/service-error")
|
||||
.body(())
|
||||
.expect("service-error request"),
|
||||
),
|
||||
);
|
||||
assert!(matches!(failed.as_mut().poll(&mut cx), Poll::Ready(Err(_))));
|
||||
let mut cancelled = Box::pin(
|
||||
service.call(
|
||||
Request::builder()
|
||||
.uri("/bucket/cancelled")
|
||||
.body(())
|
||||
.expect("cancelled request"),
|
||||
),
|
||||
);
|
||||
assert!(cancelled.as_mut().poll(&mut cx).is_pending());
|
||||
drop(cancelled);
|
||||
rustfs_io_metrics::set_metrics_enabled(previous_gate);
|
||||
});
|
||||
let samples: std::collections::BTreeMap<_, _> = recorder
|
||||
.snapshotter()
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.filter(|(key, _, _, _)| key.key().name() == "rustfs_s3_http_entry_to_dispatch_duration_seconds")
|
||||
.map(|(key, _, _, value)| {
|
||||
let labels: std::collections::BTreeMap<_, _> =
|
||||
key.key().labels().map(|label| (label.key(), label.value())).collect();
|
||||
assert_eq!(labels.keys().copied().collect::<Vec<_>>(), ["op", "outcome"]);
|
||||
let DebugValue::Histogram(samples) = value else {
|
||||
panic!("dispatch timing must be a histogram");
|
||||
};
|
||||
((labels["op"].to_owned(), labels["outcome"].to_owned()), samples.len())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(samples.len(), 4, "health/admin requests cannot manufacture S3 timing samples");
|
||||
for (operation, outcome) in [
|
||||
("s3:DeleteObjects", "dispatched"),
|
||||
("unknown", "4xx"),
|
||||
("unknown", "service_error"),
|
||||
("unknown", "cancelled"),
|
||||
] {
|
||||
assert_eq!(samples[&(operation.to_owned(), outcome.to_owned())], 1, "{operation}/{outcome}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_s3_http_outcomes_cover_response_error_cancel_and_exclusions_at_warn() {
|
||||
use rustfs_io_metrics::{record_s3_op, s3_http_metrics::s3_http_metrics_snapshot};
|
||||
|
||||
@@ -39,6 +39,8 @@ use std::future::Future;
|
||||
use tokio::runtime::{Builder, Handle};
|
||||
use tracing::{Instrument, info_span, warn};
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
use super::storage_api::ecstore_diagnostic;
|
||||
use crate::storage::storage_api::helper_consumer::StorageObjectInfo as ObjectInfo;
|
||||
|
||||
/// Schedules an asynchronous task on the current runtime;
|
||||
@@ -48,8 +50,16 @@ where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
// Capture before handing work to either runtime; request task-locals are not inherited.
|
||||
let context = rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context();
|
||||
let fut = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(context, fut);
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
let fut = ecstore_diagnostic::with_captured_read_version_context(
|
||||
ecstore_diagnostic::ReadVersionContextBoundary::BackgroundTask,
|
||||
fut,
|
||||
);
|
||||
#[cfg(not(feature = "diagnostic-read-version-caller"))]
|
||||
let fut = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(
|
||||
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context(),
|
||||
fut,
|
||||
);
|
||||
if let Ok(handle) = Handle::try_current() {
|
||||
drop(handle.spawn(fut));
|
||||
} else {
|
||||
@@ -462,6 +472,97 @@ mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use temp_env::{async_with_vars, with_vars};
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[tokio::test]
|
||||
async fn diagnostic_background_tasks_keep_existing_entry_across_nested_spawns() {
|
||||
use super::ecstore_diagnostic::{ReadVersionEntrySource, current_read_version_entry_source, with_read_version_entry};
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_context, current_s3_http_operation_name, observe_s3_http_operation,
|
||||
with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
let mut request = S3HttpRequestGuard::new("GET");
|
||||
let operation = request.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::GetObject);
|
||||
current_s3_http_operation_context().expect("captured GET operation")
|
||||
});
|
||||
for has_request_context in [false, true] {
|
||||
let context = has_request_context.then(|| RequestContext::from_headers(&HeaderMap::new()));
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
with_s3_http_operation_context(Some(operation), async {
|
||||
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
|
||||
super::spawn_background_with_context(context, async move {
|
||||
release_rx.await.expect("parent releases background work");
|
||||
with_read_version_entry(ReadVersionEntrySource::ConfigRead, async move {
|
||||
super::spawn_background_with_context(None, async move {
|
||||
tx.send((current_s3_http_operation_name(), current_read_version_entry_source()))
|
||||
.expect("context observer remains live");
|
||||
});
|
||||
})
|
||||
.await;
|
||||
});
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await;
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
release_tx.send(()).expect("detached background work remains alive");
|
||||
assert_eq!(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), rx)
|
||||
.await
|
||||
.expect("background completion deadline")
|
||||
.expect("context result"),
|
||||
(Some("s3:GetObject"), Some(ReadVersionEntrySource::TierConfigReload))
|
||||
);
|
||||
}
|
||||
drop(request);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
super::spawn_background(async move {
|
||||
tx.send((current_s3_http_operation_name(), current_read_version_entry_source()))
|
||||
.expect("unscoped observer remains live");
|
||||
});
|
||||
assert_eq!(rx.await.expect("unscoped task completes"), (None, None));
|
||||
}
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
#[test]
|
||||
fn diagnostic_background_tasks_keep_existing_entry_on_fallback_thread() {
|
||||
use super::ecstore_diagnostic::{ReadVersionEntrySource, current_read_version_entry_source, with_read_version_entry};
|
||||
use rustfs_io_metrics::s3_http_metrics::{
|
||||
S3HttpRequestGuard, current_s3_http_operation_context, current_s3_http_operation_name, observe_s3_http_operation,
|
||||
with_s3_http_operation_context,
|
||||
};
|
||||
|
||||
assert!(tokio::runtime::Handle::try_current().is_err());
|
||||
let mut request = S3HttpRequestGuard::new("DELETE");
|
||||
let operation = request.in_scope(|| {
|
||||
observe_s3_http_operation(S3Operation::DeleteObject);
|
||||
current_s3_http_operation_context().expect("captured DELETE operation")
|
||||
});
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
futures::executor::block_on(with_s3_http_operation_context(Some(operation), async {
|
||||
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
|
||||
super::spawn_background(async move {
|
||||
release_rx.await.expect("parent releases fallback work");
|
||||
tx.send((current_s3_http_operation_name(), current_read_version_entry_source()))
|
||||
.expect("fallback observer remains live");
|
||||
});
|
||||
})
|
||||
.await;
|
||||
}));
|
||||
drop(request);
|
||||
assert_eq!(current_read_version_entry_source(), None);
|
||||
release_tx.send(()).expect("fallback work remains alive");
|
||||
assert_eq!(
|
||||
rx.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.expect("fallback thread completes"),
|
||||
(Some("s3:DeleteObject"), Some(ReadVersionEntrySource::TierConfigReload))
|
||||
);
|
||||
assert_eq!(current_s3_http_operation_name(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_tasks_keep_operation_after_parent_scope_ends() {
|
||||
use rustfs_io_metrics::s3_http_metrics::{S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation};
|
||||
|
||||
@@ -30,11 +30,11 @@ pub(crate) mod ecstore_integrity {
|
||||
|
||||
#[cfg(feature = "diagnostic-read-version-caller")]
|
||||
pub(crate) mod ecstore_diagnostic {
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::diagnostic::current_read_version_entry_source;
|
||||
pub(crate) use rustfs_ecstore::api::diagnostic::{
|
||||
ReadVersionContextBoundary, ReadVersionEntrySource, with_read_version_spawn_context,
|
||||
ReadVersionContextBoundary, ReadVersionEntrySource, with_captured_read_version_context, with_read_version_spawn_context,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::diagnostic::{current_read_version_entry_source, with_read_version_entry};
|
||||
}
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -302,35 +302,42 @@ async fn only_one_client_collector_can_run_at_a_time() {
|
||||
#[tokio::test]
|
||||
async fn configured_proxy_and_direct_endpoint_failures_are_distinct() {
|
||||
let _guard = TEST_LOCK.lock().await;
|
||||
let proxy_probe = HttpClientProbe::new(
|
||||
"http://127.0.0.1:1",
|
||||
None,
|
||||
Some("http://127.0.0.1:9"),
|
||||
Zeroizing::new("access".to_owned()),
|
||||
Zeroizing::new("secret".to_owned()),
|
||||
Zeroizing::new(String::new()),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.expect("proxy probe");
|
||||
let measurement = measure_client(&request(ClientOperation::GetObject), &proxy_probe, &CancellationToken::new())
|
||||
.await
|
||||
.expect("typed proxy failure");
|
||||
assert_eq!(measurement.target.reason_code, ClientTargetReasonCode::ProxyFailure);
|
||||
|
||||
let direct_probe = HttpClientProbe::new(
|
||||
"http://127.0.0.1:9",
|
||||
None,
|
||||
None,
|
||||
Zeroizing::new("access".to_owned()),
|
||||
Zeroizing::new("secret".to_owned()),
|
||||
Zeroizing::new(String::new()),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.expect("direct probe");
|
||||
let measurement = measure_client(&request(ClientOperation::GetObject), &direct_probe, &CancellationToken::new())
|
||||
.await
|
||||
.expect("typed endpoint failure");
|
||||
assert_eq!(measurement.target.reason_code, ClientTargetReasonCode::EndpointUnavailable);
|
||||
// Classification must not race a short deadline when CI deschedules the test.
|
||||
let failure_budget = Duration::from_secs(20);
|
||||
for (use_proxy, expected) in [
|
||||
(true, ClientTargetReasonCode::ProxyFailure),
|
||||
(false, ClientTargetReasonCode::EndpointUnavailable),
|
||||
] {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("failure listener");
|
||||
let address = listener.local_addr().expect("failure listener address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("probe connection");
|
||||
// Reject the proxy CONNECT or direct TLS handshake before any HTTP response.
|
||||
drop(stream);
|
||||
});
|
||||
let endpoint = format!("https://{address}");
|
||||
let proxy = format!("http://{address}");
|
||||
let probe = HttpClientProbe::new(
|
||||
&endpoint,
|
||||
None,
|
||||
use_proxy.then_some(proxy.as_str()),
|
||||
Zeroizing::new("access".to_owned()),
|
||||
Zeroizing::new("secret".to_owned()),
|
||||
Zeroizing::new(String::new()),
|
||||
failure_budget,
|
||||
)
|
||||
.expect("failure probe");
|
||||
let mut probe_request = request(ClientOperation::GetObject);
|
||||
probe_request.duration = failure_budget;
|
||||
let measurement = measure_client(&probe_request, &probe, &CancellationToken::new())
|
||||
.await
|
||||
.expect("typed connection failure");
|
||||
assert_eq!(measurement.target.reason_code, expected);
|
||||
tokio::time::timeout(failure_budget, server)
|
||||
.await
|
||||
.expect("failure server must finish")
|
||||
.expect("failure server must not panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user