diff --git a/Cargo.lock b/Cargo.lock index 5482a61..be5c6a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -89,12 +95,47 @@ version = "59.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e87acb0c6bf6237fca7a50d7b51e801c98a18a5fe6f3bb34f7d77b85ca165388" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "benchfmt" version = "0.1.0" @@ -125,6 +166,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -134,6 +177,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -145,6 +205,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-random" version = "0.1.18" @@ -165,18 +244,63 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64" +dependencies = [ + "cfg-if", +] + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.19.0" @@ -196,7 +320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -217,6 +341,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -229,12 +363,42 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -260,8 +424,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -272,10 +438,43 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -309,6 +508,105 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -333,6 +631,110 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -343,6 +745,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itertools" version = "0.14.0" @@ -358,6 +766,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.107" @@ -387,6 +854,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.34" @@ -425,6 +898,12 @@ dependencies = [ "logos-codegen", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "memchr" version = "2.8.3" @@ -453,6 +932,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "multimap" version = "0.10.1" @@ -503,6 +1003,18 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "petgraph" version = "0.8.3" @@ -520,6 +1032,21 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -569,8 +1096,14 @@ dependencies = [ name = "prometheus-scrape-manager" version = "0.1.0" dependencies = [ + "benchfmt", + "flate2", + "futures-util", "prometheus-expfmt", "prometheus-relabel", + "reqwest", + "thiserror", + "tokio", ] [[package]] @@ -663,6 +1196,63 @@ dependencies = [ "thiserror", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -678,6 +1268,38 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "regex" version = "1.13.1" @@ -707,6 +1329,71 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.6" @@ -717,7 +1404,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102f35f7de7b06d8c61f1cff6592f5253e0dea431c74867aad5dfd70f44663fb" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -726,6 +1488,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -775,12 +1584,62 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -803,6 +1662,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -812,7 +1691,7 @@ dependencies = [ "fastrand", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -844,6 +1723,142 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -856,12 +1871,64 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -889,6 +1956,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.130" @@ -921,6 +1999,44 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293198dcebfb821155b5092ab94b5a82ff180eddd7241df3bd6e66a4ffd1a585" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -980,6 +2096,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -989,12 +2114,105 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -1015,6 +2233,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index f1bc98a..6131da3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,14 +22,18 @@ arrow-array = { version = "59", default-features = false } arrow-buffer = { version = "59", default-features = false } arrow-schema = { version = "59", default-features = false } benchfmt = { git = "https://github.com/bwplotka/benchfmt.rs" } +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +futures-util = { version = "0.3", default-features = false, features = ["alloc"] } prost = { version = "0.14" } prost-build = "=0.14.4" prost-types = { version = "0.14" } protox = "0.10" regex = { version = "1" } +reqwest = { version = "0.13", default-features = false, features = ["http2", "rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" +tokio = { version = "1", default-features = false, features = ["rt", "sync", "time"] } [workspace.lints.rust] missing_docs = "warn" diff --git a/README.md b/README.md index 2968c7c..b8da2e2 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The project serves two main use cases: | :--- | :--- | :--- | :--- | :--- | | `prometheus-expfmt` | [`crates/expfmt`](./crates/expfmt) | In progress | [specs/SPEC.md](./specs/SPEC.md) | `Content-Type` and `Accept` negotiation, zero-copy streaming parsers for Prometheus Text 0.0.4, OpenMetrics 1.0, OpenMetrics 2.0, and Prometheus Protobuf, primary Apache Arrow / OTAP decoder (`ArrowDecoder`), and [`MetricFamily`](https://github.com/prometheus/prometheus/blob/main/prompb/io/prometheus/client/metrics.proto) protobuf DTO decoder. | | `prometheus-relabel` | [`crates/relabel`](./crates/relabel) | In progress | [specs/SPEC.md](./specs/SPEC.md#target-and-metric-relabeling-architecture) | Prometheus-compatible target and metric relabeling (`replace`, `keep`, `drop`, `hashmod`, `labelmap`, `labeldrop`, `labelkeep`, `lowercase`, `uppercase`, `keepequal`, `dropequal`), Stage 1 `__name__` filter pushdown, and Stage 2 dictionary-vectorized relabeling and `honor_labels` resolution. | -| `prometheus-scrape-manager` | [`crates/scrape-manager`](./crates/scrape-manager) | Planned | TBD | Prometheus HTTP/TLS client configuration (`http_config`), service discovery mechanisms (`discovery`: static, file, HTTP, DNS, Kubernetes), and bounded target scrape pool scheduling (`scrape`). | +| `prometheus-scrape-manager` | [`crates/scrape-manager`](./crates/scrape-manager) | In progress | [specs/SPEC.md](./specs/SPEC.md#bounded-scrape-scheduling-and-memory-pools) | Bounded concurrent and sequential target scrape scheduling (`scrape`), Prometheus HTTP/TLS client configuration (`http_config`), and service discovery mechanisms (`discovery`: static, file, HTTP, DNS, Kubernetes). | ## Development and testing @@ -52,9 +52,10 @@ Run the Go differential fixture extractor and tests (requires Go): cd tests/go-harness && go test ./... ``` -Run the decoding and relabeling benchmarks in Go `benchfmt` format for [`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat): +Run the decoding, relabeling, and scrape scheduling benchmarks in Go `benchfmt` format for [`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat): ```sh cargo bench -p prometheus-expfmt --bench decode -- -bench '^BenchmarkDecode' -benchtime 1s -count 6 -cpu 2 -benchmem | tee decode.txt cargo bench -p prometheus-relabel --bench relabel -- -bench '^BenchmarkRelabel' -benchtime 1s -count 6 -cpu 2 -benchmem | tee relabel.txt +cargo bench -p prometheus-scrape-manager --bench scrape -- -bench '^BenchmarkScrape' -benchtime 500ms -count 6 -cpu 2 -benchmem | tee scrape.txt ``` diff --git a/crates/scrape-manager/Cargo.toml b/crates/scrape-manager/Cargo.toml index c515f63..4b7ecef 100644 --- a/crates/scrape-manager/Cargo.toml +++ b/crates/scrape-manager/Cargo.toml @@ -9,6 +9,10 @@ description = "Prometheus service discovery, HTTP client configuration, and scra readme = "README.md" keywords = ["prometheus", "openmetrics", "metrics", "scrape", "service-discovery"] categories = ["network-programming", "asynchronous"] +exclude = [ + "benches/**", + "tests/**", +] # Kept unpublished while initial service discovery, HTTP config, and scrape pool modules land. publish = false @@ -17,8 +21,22 @@ default = ["arrow"] arrow = ["prometheus-expfmt/arrow"] [dependencies] +flate2 = { workspace = true } +futures-util = { workspace = true } prometheus-expfmt = { workspace = true } prometheus-relabel = { workspace = true } +reqwest = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +benchfmt = { workspace = true } +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt-multi-thread", "test-util"] } + +[[bench]] +name = "scrape" +harness = false +required-features = ["arrow"] [lints] workspace = true diff --git a/crates/scrape-manager/README.md b/crates/scrape-manager/README.md index 17c1927..dd6e7f5 100644 --- a/crates/scrape-manager/README.md +++ b/crates/scrape-manager/README.md @@ -1,5 +1,14 @@ # prometheus-scrape-manager -Prometheus service discovery (`discovery`), HTTP/TLS client configuration (`http_config`), and target scrape pool management (`scrape`) in Rust, built on [`prometheus-expfmt`](../expfmt) and [`prometheus-relabel`](../relabel). +Prometheus target scrape pool management (`scrape`), HTTP/TLS client configuration (`http_config`), and service discovery (`discovery`) in Rust, built on [`prometheus-expfmt`](../expfmt) and [`prometheus-relabel`](../relabel). + +## Scrape scheduling modes + +`scrape::ScrapeManager` supports two scheduling strategies configured via `SchedulingMode`: + +- `SchedulingMode::Concurrent` schedules every target on its own deterministic 64-bit FNV-1a phase offset modulo `scrape_interval` inside a single min-heap. Up to `max_in_flight` HTTP requests wait for response headers in parallel while holding zero payload memory. Only after an HTTP `200 OK` header arrives does the fetcher lease one of `max_buffered_bodies` reusable `Vec` buffers from `BodyBufferPool`, and decoding leases one of `max_decoders` reusable `ArrowDecoder` instances from `DecoderPool`. +- `SchedulingMode::Sequential` scrapes one target at a time using a single reusable body buffer and a single decoder, either on a job-wide tick via `SequentialSchedule::CycleTick` or across per-target FNV-1a phase offsets via `SequentialSchedule::Jittered`. + +The scrape loop drives concurrent futures with `FuturesUnordered` rather than `tokio::spawn`, so thread-per-core `!Send` `LocalSet` runtimes can pass non-`Send` sinks directly. Part of the [`prometheus/scrape.rs`](https://github.com/prometheus/scrape.rs) workspace. diff --git a/crates/scrape-manager/benches/scrape.rs b/crates/scrape-manager/benches/scrape.rs new file mode 100644 index 0000000..75e72c5 --- /dev/null +++ b/crates/scrape-manager/benches/scrape.rs @@ -0,0 +1,193 @@ +// Copyright 2026 The Prometheus Authors +// 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. + +//! Throughput and allocation benchmarks for `prometheus_scrape_manager::scrape::ScrapeManager` +//! comparing sequential and bounded concurrent scrape modes over the upstream Prometheus +//! `model/textparse` fixture corpus. +//! +//! Recommended CLI invocation: +//! ```sh +//! cargo bench -p prometheus-scrape-manager --bench scrape -- -bench '^BenchmarkScrape' -benchtime 500ms -count 6 -cpu 2 -benchmem | tee ${bench}.txt +//! ``` + +use std::fs; +use std::hint::black_box; +use std::num::NonZeroUsize; +use std::ops::ControlFlow; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use benchfmt::{CountingAlloc, Suite}; +use prometheus_expfmt::arrow::OtapMetricRecords; +use prometheus_expfmt::format::Format; +use prometheus_scrape_manager::scrape::{ + ArrowScrapeSink, BodyBufferPool, FetchedScrape, SchedulingMode, ScrapeConfig, ScrapeError, + ScrapeManager, ScrapeReport, ScrapeTarget, TargetFetcher, +}; + +#[global_allocator] +static ALLOC: CountingAlloc = CountingAlloc::new(); + +#[derive(Clone)] +struct BenchFetcher { + payload: Arc<[u8]>, + header_wait: Duration, +} + +impl TargetFetcher for BenchFetcher { + async fn fetch( + &self, + _target: &ScrapeTarget, + body_pool: &BodyBufferPool, + ) -> Result { + // Simulate waiting on target TCP connect and server-side `/metrics` generation before + // HTTP 200 headers arrive. No body buffer or decoder is leased during this wait. + if self.header_wait.is_zero() { + tokio::task::yield_now().await; + } else { + tokio::time::sleep(self.header_wait).await; + } + + let mut body = body_pool.acquire(self.payload.len()).await; + body.extend_from_slice(&self.payload); + + Ok(FetchedScrape { + body, + format: Format::TEXT, + scrape_timestamp_ms: 1_700_000_000_000, + wire_bytes: self.payload.len(), + fetch_duration: self.header_wait, + }) + } +} + +struct BlackBoxSink; + +impl ArrowScrapeSink for BlackBoxSink { + async fn on_batch( + &self, + target: &ScrapeTarget, + scrape_timestamp_ms: i64, + records: OtapMetricRecords, + ) -> ControlFlow<()> { + black_box((target, scrape_timestamp_ms, records)); + ControlFlow::Continue(()) + } + + async fn on_scrape_complete( + &self, + target: &ScrapeTarget, + report: ScrapeReport, + ) -> ControlFlow<()> { + black_box((target, report)); + ControlFlow::Continue(()) + } +} + +fn build_targets(count: usize) -> Vec { + (0..count) + .map(|i| { + ScrapeTarget::parse(&format!("http://10.0.{}.{}:9100/metrics", i / 256, i % 256)) + .expect("valid target url") + .with_label("job", "node") + .with_label("instance", format!("node-{i}")) + }) + .collect() +} + +fn main() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../testdata/textparse"); + let full = fs::read(root.join("alltypes.237mfs.prom.txt")).expect("read fixture"); + let payload: Arc<[u8]> = Arc::from(full.into_boxed_slice()); + + let targets = Arc::new(build_targets(16)); + let total_bytes = (payload.len() * targets.len()) as u64; + + let modes: [(&str, SchedulingMode, usize, usize); 3] = [ + ("sequential_tick", SchedulingMode::sequential(), 1, 1), + ( + "concurrent_k8_b2_d1", + SchedulingMode::concurrent_with_pools( + NonZeroUsize::new(8).unwrap(), + NonZeroUsize::new(2).unwrap(), + NonZeroUsize::new(1).unwrap(), + ), + 2, + 1, + ), + ( + "concurrent_k8_b8_d4", + SchedulingMode::concurrent_with_pools( + NonZeroUsize::new(8).unwrap(), + NonZeroUsize::new(8).unwrap(), + NonZeroUsize::new(4).unwrap(), + ), + 8, + 4, + ), + ]; + + let mut suite = Suite::from_env("prometheus-scrape-manager").with_alloc(&ALLOC); + + // Tokio's 1ms timer-wheel rounding plus OS timer slack makes `Duration::from_millis(1)` + // wait ~2.2ms per fetch in practice, so label the dimension `2ms` to reflect actual wait time. + for (wait_label, header_wait) in [("none", Duration::ZERO), ("2ms", Duration::from_millis(1))] { + for (mode_label, scheduling, max_bodies, max_decoders) in modes { + let payload = Arc::clone(&payload); + let targets = Arc::clone(&targets); + + suite = suite.bench( + format!("ScrapeCycle/targets=16/header_wait={wait_label}/mode={mode_label}"), + move |b| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .expect("current_thread runtime"); + + let fetcher = BenchFetcher { + payload: Arc::clone(&payload), + header_wait, + }; + let cfg = ScrapeConfig::new("bench") + .with_scheduling(scheduling) + .with_max_data_points_per_batch(256); + let manager = ScrapeManager::new(cfg, fetcher).expect("manager"); + let sink = BlackBoxSink; + + // Warm pool buffers and decoder interner arenas once so B/op and allocs/op + // reflect steady-state scrape reuse rather than first-pass capacity growth. + rt.block_on(async { + let _ = manager.run_once_arrow(&targets, &sink).await; + }); + let warm_stats = manager.stats(); + assert!(warm_stats.total_body_buffers_created <= max_bodies); + assert!(warm_stats.total_decoders_created <= max_decoders); + + b.set_bytes(total_bytes); + b.report_allocs(); + while b.keep_running() { + rt.block_on(async { + let out = manager + .run_once_arrow(black_box(targets.as_slice()), &sink) + .await; + let _ = black_box(out); + }); + } + }, + ); + } + } + + suite.run(); +} diff --git a/crates/scrape-manager/src/lib.rs b/crates/scrape-manager/src/lib.rs index 5378518..322d658 100644 --- a/crates/scrape-manager/src/lib.rs +++ b/crates/scrape-manager/src/lib.rs @@ -14,7 +14,9 @@ //! Prometheus service discovery, HTTP scrape client configuration, and target scrape management. //! //! Re-exports [`prometheus_expfmt`] and [`prometheus_relabel`] and hosts the async runtime I/O -//! layers (`http_config`, `discovery`, and `scrape`) for [`prometheus/scrape.rs`](https://github.com/prometheus/scrape.rs). +//! layers for [`prometheus/scrape.rs`](https://github.com/prometheus/scrape.rs). pub use prometheus_expfmt as expfmt; pub use prometheus_relabel as relabel; + +pub mod scrape; diff --git a/crates/scrape-manager/src/scrape.rs b/crates/scrape-manager/src/scrape.rs new file mode 100644 index 0000000..475cdce --- /dev/null +++ b/crates/scrape-manager/src/scrape.rs @@ -0,0 +1,50 @@ +// Copyright 2026 The Prometheus Authors +// 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. + +//! Bounded-memory HTTP target scraping and interval scheduling. +//! +//! [`ScrapeManager`] coordinates periodic target scraping in two user-selectable modes via +//! [`SchedulingMode`]: +//! - [`SchedulingMode::Sequential`]: scrapes one target at a time with a single reusable HTTP body +//! buffer and a single decoder, using either a job-wide tick ([`SequentialSchedule::CycleTick`]) +//! or deterministic per-target FNV-1a phase offsets ([`SequentialSchedule::Jittered`]). +//! - [`SchedulingMode::Concurrent`]: schedules every target on its own deterministic jittered +//! interval (`hash(target) % scrape_interval`) while decoupling in-flight HTTP header concurrency +//! (`max_in_flight`) from pooled response body buffers (`max_buffered_bodies`) and pooled +//! decoders (`max_decoders`). Slow targets stalled on TCP connect or server-side metric +//! generation neither delay sibling targets nor hold response payload buffers. + +mod config; +mod http; +mod manager; +mod pool; +mod schedule; + +pub use config::{ + DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_DATA_POINTS_PER_BATCH, DEFAULT_MAX_RETAINED_BODY_BYTES, + DEFAULT_MAX_RETAINED_DECODER_BYTES, DEFAULT_SCRAPE_INTERVAL, DEFAULT_SCRAPE_TIMEOUT, + DEFAULT_USER_AGENT, SchedulingMode, ScrapeConfig, ScrapeTarget, SequentialSchedule, +}; +pub use http::{ + FetchedScrape, ReqwestFetcher, ScrapeError, TargetFetcher, build_accept_header, + format_scrape_timeout_seconds, resolve_scrape_format, +}; +#[cfg(feature = "arrow")] +pub use manager::ArrowScrapeSink; +pub use manager::{ + ManagerStats, RawScrapeSink, ScrapeCommand, ScrapeControl, ScrapeManager, ScrapeReport, +}; +pub use pool::{BodyBufferPool, PooledBodyBuffer}; +#[cfg(feature = "arrow")] +pub use pool::{DecoderPool, PooledDecoder}; +pub use schedule::target_offset; diff --git a/crates/scrape-manager/src/scrape/config.rs b/crates/scrape-manager/src/scrape/config.rs new file mode 100644 index 0000000..46f611a --- /dev/null +++ b/crates/scrape-manager/src/scrape/config.rs @@ -0,0 +1,582 @@ +// Copyright 2026 The Prometheus Authors +// 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 std::collections::BTreeMap; +use std::num::NonZeroUsize; +use std::time::Duration; + +#[cfg(feature = "arrow")] +use prometheus_expfmt::arrow::{DecodeBudget, DecodeOptions}; +use prometheus_expfmt::format::EscapingScheme; + +use crate::scrape::ScrapeError; + +/// Default interval between scrapes of the same target (`15s`). +pub const DEFAULT_SCRAPE_INTERVAL: Duration = Duration::from_secs(15); + +/// Default per-target HTTP scrape timeout (`10s`). +pub const DEFAULT_SCRAPE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Default maximum uncompressed HTTP response body size (`16 MiB`). +pub const DEFAULT_MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +/// Default cap on `Vec` capacity retained in [`crate::scrape::BodyBufferPool`] (`4 MiB`). +pub const DEFAULT_MAX_RETAINED_BODY_BYTES: usize = 4 * 1024 * 1024; + +/// Default cap on scratch capacity retained per idle decoder in [`crate::scrape::DecoderPool`] (`4 MiB`). +pub const DEFAULT_MAX_RETAINED_DECODER_BYTES: usize = 4 * 1024 * 1024; + +/// Default maximum data points per emitted Arrow chunk (`2,048`). +pub const DEFAULT_MAX_DATA_POINTS_PER_BATCH: usize = 2_048; + +/// Default `User-Agent` header sent on scrape HTTP requests. +pub const DEFAULT_USER_AGENT: &str = + concat!("prometheus-scrape-manager/", env!("CARGO_PKG_VERSION")); + +const DEFAULT_CONCURRENT_IN_FLIGHT: usize = 8; +const DEFAULT_CONCURRENT_BUFFERED_BODIES: usize = 2; +const MAX_SCHEDULE_DURATION: Duration = Duration::from_secs(365 * 24 * 60 * 60); + +/// Single HTTP endpoint and associated target labels scheduled by a scrape pool. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ScrapeTarget { + url: reqwest::Url, + labels: BTreeMap, +} + +impl ScrapeTarget { + /// Creates a new [`ScrapeTarget`] for `url` with an empty target label set. + #[must_use] + pub const fn new(url: reqwest::Url) -> Self { + Self { + url, + labels: BTreeMap::new(), + } + } + + /// Parses `raw_url` into a [`ScrapeTarget`]. + /// + /// # Errors + /// + /// Returns [`ScrapeError::InvalidConfig`] if `raw_url` is not a valid HTTP or HTTPS URL. + pub fn parse(raw_url: &str) -> Result { + let url = reqwest::Url::parse(raw_url).map_err(|err| { + ScrapeError::InvalidConfig(format!("invalid scrape target URL {raw_url:?}: {err}")) + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ScrapeError::InvalidConfig(format!( + "unsupported scrape target URL scheme {:?} in {raw_url:?}", + url.scheme() + ))); + } + Ok(Self::new(url)) + } + + /// Inserts or replaces a target metadata label (such as `instance` or `job`). + #[must_use] + pub fn with_label(mut self, key: impl Into, value: impl Into) -> Self { + self.labels.insert(key.into(), value.into()); + self + } + + /// Inserts all `(key, value)` pairs into the target's label set. + #[must_use] + pub fn with_labels(mut self, iter: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + for (k, v) in iter { + self.labels.insert(k.into(), v.into()); + } + self + } + + /// Returns the scrape endpoint URL. + #[must_use] + pub const fn url(&self) -> &reqwest::Url { + &self.url + } + + /// Returns the sorted target label map. + #[must_use] + pub const fn labels(&self) -> &BTreeMap { + &self.labels + } +} + +/// Cadence policy when [`SchedulingMode::Sequential`] is selected. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SequentialSchedule { + /// Runs all configured targets back-to-back on a single job-wide interval tick. + /// + /// Reproduces the prototype single-loop cadence where one timer wakes every `scrape_interval` + /// and iterates targets sequentially. + #[default] + CycleTick, + /// Schedules each target at its deterministic `hash(target) % scrape_interval` offset and + /// executes due targets one at a time in due-timestamp order. + Jittered, +} + +/// Target scheduling and concurrency strategy for a scrape pool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SchedulingMode { + /// Executes at most one scrape at a time using one reusable HTTP body buffer and one decoder. + Sequential { + /// Cadence policy for sequential target execution. + schedule: SequentialSchedule, + }, + /// Schedules each target on an independent deterministic jittered timer while bounding + /// in-flight HTTP requests, buffered response bodies, and pooled decoders separately. + Concurrent { + /// Maximum concurrent in-flight HTTP scrape requests (`>= 1`). + max_in_flight: NonZeroUsize, + /// Maximum HTTP response bodies buffered in memory simultaneously (`1..=max_in_flight`). + max_buffered_bodies: NonZeroUsize, + /// Maximum reusable decoders pooled simultaneously (`1..=max_in_flight`). + max_decoders: NonZeroUsize, + }, +} + +impl Default for SchedulingMode { + fn default() -> Self { + Self::concurrent( + NonZeroUsize::new(DEFAULT_CONCURRENT_IN_FLIGHT).expect("non-zero constant"), + ) + } +} + +impl SchedulingMode { + /// Sequential cycle-tick mode: scrapes all targets one after another on each interval tick. + #[must_use] + pub const fn sequential() -> Self { + Self::Sequential { + schedule: SequentialSchedule::CycleTick, + } + } + + /// Sequential jittered mode: schedules per-target deterministic offsets while executing at + /// most one scrape at a time. + #[must_use] + pub const fn sequential_jittered() -> Self { + Self::Sequential { + schedule: SequentialSchedule::Jittered, + } + } + + /// Concurrent jittered mode with `max_in_flight` HTTP concurrency and up to `2` (clamped to + /// `max_in_flight`) concurrent buffered bodies and pooled decoders. + #[must_use] + pub fn concurrent(max_in_flight: NonZeroUsize) -> Self { + let pool_cap = max_in_flight.get().min(DEFAULT_CONCURRENT_BUFFERED_BODIES); + let max_buffered_bodies = NonZeroUsize::new(pool_cap).expect("min of >=1 and 2 is >=1"); + let max_decoders = max_buffered_bodies; + Self::Concurrent { + max_in_flight, + max_buffered_bodies, + max_decoders, + } + } + + /// Concurrent jittered mode with explicit caps on in-flight HTTP requests, buffered response + /// bodies, and pooled decoders. + #[must_use] + pub const fn concurrent_with_pools( + max_in_flight: NonZeroUsize, + max_buffered_bodies: NonZeroUsize, + max_decoders: NonZeroUsize, + ) -> Self { + Self::Concurrent { + max_in_flight, + max_buffered_bodies, + max_decoders, + } + } + + /// Returns the maximum number of HTTP requests allowed in flight simultaneously. + #[must_use] + pub const fn max_in_flight(&self) -> NonZeroUsize { + match *self { + Self::Sequential { .. } => NonZeroUsize::MIN, + Self::Concurrent { max_in_flight, .. } => max_in_flight, + } + } + + /// Returns the maximum number of response body buffers leased simultaneously. + #[must_use] + pub const fn max_buffered_bodies(&self) -> NonZeroUsize { + match *self { + Self::Sequential { .. } => NonZeroUsize::MIN, + Self::Concurrent { + max_in_flight, + max_buffered_bodies, + .. + } => { + if max_buffered_bodies.get() > max_in_flight.get() { + max_in_flight + } else { + max_buffered_bodies + } + } + } + } + + /// Returns the maximum number of decoders pooled simultaneously. + #[must_use] + pub const fn max_decoders(&self) -> NonZeroUsize { + match *self { + Self::Sequential { .. } => NonZeroUsize::MIN, + Self::Concurrent { + max_in_flight, + max_decoders, + .. + } => { + if max_decoders.get() > max_in_flight.get() { + max_in_flight + } else { + max_decoders + } + } + } + } +} + +/// Configuration for [`crate::scrape::ReqwestFetcher`] and scrape pool scheduling. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct ScrapeConfig { + /// Logical Prometheus `job` name associated with this scrape pool. + pub job_name: String, + /// Target scrape cadence (`> 0`). + pub scrape_interval: Duration, + /// Per-scrape HTTP + body read timeout (`0 < scrape_timeout <= scrape_interval`). + pub scrape_timeout: Duration, + /// Optional startup delay applied before the first scrape cycle. + pub initial_delay: Duration, + /// Seed mixed into deterministic per-target FNV-1a offset hashing. + pub jitter_seed: u64, + /// Sequential vs bounded-concurrent scheduling policy. + pub scheduling: SchedulingMode, + /// Advertise `application/openmetrics-text` in the `Accept` header when `true`. + pub enable_open_metrics: bool, + /// UTF-8 metric/label name escaping scheme advertised in `Accept`. + pub escaping_scheme: EscapingScheme, + /// Negotiate and transparently decompress `Content-Encoding: gzip` responses when `true`. + pub enable_compression: bool, + /// Maximum uncompressed HTTP response body size in bytes (`0` disables the limit). + pub max_body_bytes: usize, + /// Maximum capacity in bytes retained per idle `Vec` in [`crate::scrape::BodyBufferPool`]. + pub max_retained_body_bytes: usize, + /// Maximum scratch capacity in bytes retained per idle decoder in [`crate::scrape::DecoderPool`] + /// (`0` drops idle decoders after every scrape). + pub max_retained_decoder_bytes: usize, + /// Maximum data points emitted per decoded Arrow chunk (`0` decodes each scrape as one batch). + pub max_data_points_per_batch: usize, + /// `User-Agent` header value sent on HTTP scrape requests. + pub user_agent: String, + /// Cooperative per-step parsing budget when the `arrow` feature is enabled. + #[cfg(feature = "arrow")] + pub decode_budget: DecodeBudget, + /// Base `ArrowDecoder` translation options when the `arrow` feature is enabled. + #[cfg(feature = "arrow")] + pub decode_options: DecodeOptions, +} + +impl Default for ScrapeConfig { + fn default() -> Self { + Self { + job_name: "default".to_owned(), + scrape_interval: DEFAULT_SCRAPE_INTERVAL, + scrape_timeout: DEFAULT_SCRAPE_TIMEOUT, + initial_delay: Duration::ZERO, + jitter_seed: 0, + scheduling: SchedulingMode::default(), + enable_open_metrics: true, + escaping_scheme: EscapingScheme::default(), + enable_compression: true, + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + max_retained_body_bytes: DEFAULT_MAX_RETAINED_BODY_BYTES, + max_retained_decoder_bytes: DEFAULT_MAX_RETAINED_DECODER_BYTES, + max_data_points_per_batch: DEFAULT_MAX_DATA_POINTS_PER_BATCH, + user_agent: DEFAULT_USER_AGENT.to_owned(), + #[cfg(feature = "arrow")] + decode_budget: DecodeBudget::default(), + #[cfg(feature = "arrow")] + decode_options: DecodeOptions::new(), + } + } +} + +impl ScrapeConfig { + /// Creates a [`ScrapeConfig`] for `job_name` with default intervals and concurrent scheduling. + #[must_use] + pub fn new(job_name: impl Into) -> Self { + Self { + job_name: job_name.into(), + ..Self::default() + } + } + + /// Sets [`ScrapeConfig::scrape_interval`]. + #[must_use] + pub const fn with_scrape_interval(mut self, interval: Duration) -> Self { + self.scrape_interval = interval; + self + } + + /// Sets [`ScrapeConfig::scrape_timeout`]. + #[must_use] + pub const fn with_scrape_timeout(mut self, timeout: Duration) -> Self { + self.scrape_timeout = timeout; + self + } + + /// Sets [`ScrapeConfig::initial_delay`]. + #[must_use] + pub const fn with_initial_delay(mut self, delay: Duration) -> Self { + self.initial_delay = delay; + self + } + + /// Sets [`ScrapeConfig::jitter_seed`]. + #[must_use] + pub const fn with_jitter_seed(mut self, seed: u64) -> Self { + self.jitter_seed = seed; + self + } + + /// Sets [`ScrapeConfig::scheduling`]. + #[must_use] + pub const fn with_scheduling(mut self, scheduling: SchedulingMode) -> Self { + self.scheduling = scheduling; + self + } + + /// Sets [`ScrapeConfig::enable_open_metrics`]. + #[must_use] + pub const fn with_enable_open_metrics(mut self, enable: bool) -> Self { + self.enable_open_metrics = enable; + self + } + + /// Sets [`ScrapeConfig::escaping_scheme`]. + #[must_use] + pub const fn with_escaping_scheme(mut self, scheme: EscapingScheme) -> Self { + self.escaping_scheme = scheme; + self + } + + /// Sets [`ScrapeConfig::enable_compression`]. + #[must_use] + pub const fn with_enable_compression(mut self, enable: bool) -> Self { + self.enable_compression = enable; + self + } + + /// Sets [`ScrapeConfig::max_body_bytes`]. + #[must_use] + pub const fn with_max_body_bytes(mut self, max_bytes: usize) -> Self { + self.max_body_bytes = max_bytes; + self + } + + /// Sets [`ScrapeConfig::max_retained_body_bytes`]. + #[must_use] + pub const fn with_max_retained_body_bytes(mut self, max_bytes: usize) -> Self { + self.max_retained_body_bytes = max_bytes; + self + } + + /// Sets [`ScrapeConfig::max_retained_decoder_bytes`]. + #[must_use] + pub const fn with_max_retained_decoder_bytes(mut self, max_bytes: usize) -> Self { + self.max_retained_decoder_bytes = max_bytes; + self + } + + /// Sets [`ScrapeConfig::max_data_points_per_batch`]. + #[must_use] + pub const fn with_max_data_points_per_batch(mut self, max_points: usize) -> Self { + self.max_data_points_per_batch = max_points; + self + } + + /// Sets [`ScrapeConfig::user_agent`]. + #[must_use] + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + /// Sets [`ScrapeConfig::decode_budget`]. + #[cfg(feature = "arrow")] + #[must_use] + pub const fn with_decode_budget(mut self, budget: DecodeBudget) -> Self { + self.decode_budget = budget; + self + } + + /// Sets [`ScrapeConfig::decode_options`]. + #[cfg(feature = "arrow")] + #[must_use] + pub const fn with_decode_options(mut self, options: DecodeOptions) -> Self { + self.decode_options = options; + self + } + + /// Validates interval, timeout, and pool invariants. + /// + /// # Errors + /// + /// Returns [`ScrapeError::InvalidConfig`] if `job_name` is empty, `scrape_interval` or + /// `scrape_timeout` is zero, `scrape_timeout > scrape_interval`, or a concurrent pool size + /// exceeds `max_in_flight`. + pub fn validate(&self) -> Result<(), ScrapeError> { + if self.job_name.trim().is_empty() { + return Err(ScrapeError::InvalidConfig( + "job_name must not be empty".to_owned(), + )); + } + if self.scrape_interval.is_zero() { + return Err(ScrapeError::InvalidConfig( + "scrape_interval must be greater than zero".to_owned(), + )); + } + if self.scrape_interval > MAX_SCHEDULE_DURATION { + return Err(ScrapeError::InvalidConfig(format!( + "scrape_interval ({:?}) exceeds maximum ({MAX_SCHEDULE_DURATION:?})", + self.scrape_interval + ))); + } + if self.initial_delay > MAX_SCHEDULE_DURATION { + return Err(ScrapeError::InvalidConfig(format!( + "initial_delay ({:?}) exceeds maximum ({MAX_SCHEDULE_DURATION:?})", + self.initial_delay + ))); + } + if self.scrape_timeout.is_zero() { + return Err(ScrapeError::InvalidConfig( + "scrape_timeout must be greater than zero".to_owned(), + )); + } + if self.scrape_timeout > self.scrape_interval { + return Err(ScrapeError::InvalidConfig(format!( + "scrape_timeout ({:?}) must not exceed scrape_interval ({:?})", + self.scrape_timeout, self.scrape_interval + ))); + } + if reqwest::header::HeaderValue::from_str(&self.user_agent).is_err() { + return Err(ScrapeError::InvalidConfig(format!( + "user_agent contains invalid HTTP header bytes: {:?}", + self.user_agent + ))); + } + if let SchedulingMode::Concurrent { + max_in_flight, + max_buffered_bodies, + max_decoders, + } = self.scheduling + { + if max_buffered_bodies > max_in_flight { + return Err(ScrapeError::InvalidConfig(format!( + "max_buffered_bodies ({max_buffered_bodies}) must not exceed max_in_flight ({max_in_flight})" + ))); + } + if max_decoders > max_in_flight { + return Err(ScrapeError::InvalidConfig(format!( + "max_decoders ({max_decoders}) must not exceed max_in_flight ({max_in_flight})" + ))); + } + } + Ok(()) + } + + /// Effective uncompressed response body byte limit (`usize::MAX` when `max_body_bytes == 0`). + #[must_use] + pub const fn effective_max_body_bytes(&self) -> usize { + if self.max_body_bytes == 0 { + usize::MAX + } else { + self.max_body_bytes + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scrape_target_parse_enforces_http_and_https_schemes() { + let http = ScrapeTarget::parse("http://127.0.0.1:9090/metrics") + .expect("http") + .with_labels([("job", "prom"), ("env", "prod")]); + assert_eq!(http.labels().len(), 2); + + let https = ScrapeTarget::parse("https://example.com/metrics").expect("https"); + assert_eq!(https.url().scheme(), "https"); + + assert!(matches!( + ScrapeTarget::parse("ftp://example.com/metrics"), + Err(ScrapeError::InvalidConfig(_)) + )); + assert!(matches!( + ScrapeTarget::parse("not a url"), + Err(ScrapeError::InvalidConfig(_)) + )); + } + + #[test] + fn scheduling_mode_and_config_validation_cover_edge_cases() { + let one = NonZeroUsize::MIN; + let two = NonZeroUsize::new(2).unwrap(); + let c1 = SchedulingMode::concurrent(one); + assert_eq!(c1.max_in_flight(), one); + assert_eq!(c1.max_buffered_bodies(), one); + assert_eq!(c1.max_decoders(), one); + + let clamped = SchedulingMode::concurrent_with_pools(one, two, two); + assert_eq!(clamped.max_buffered_bodies(), one); + assert_eq!(clamped.max_decoders(), one); + + let unlimited = ScrapeConfig::new("job").with_max_body_bytes(0); + assert_eq!(unlimited.effective_max_body_bytes(), usize::MAX); + assert!(unlimited.validate().is_ok()); + + let invalid_cases = [ + ScrapeConfig::new(" "), + ScrapeConfig::new("job").with_scrape_interval(Duration::ZERO), + ScrapeConfig::new("job") + .with_scrape_interval(MAX_SCHEDULE_DURATION + Duration::from_secs(1)), + ScrapeConfig::new("job") + .with_initial_delay(MAX_SCHEDULE_DURATION + Duration::from_secs(1)), + ScrapeConfig::new("job").with_scrape_timeout(Duration::ZERO), + ScrapeConfig::new("job") + .with_scrape_interval(Duration::from_secs(5)) + .with_scrape_timeout(Duration::from_secs(6)), + ScrapeConfig::new("job").with_user_agent("bad\r\nheader"), + ScrapeConfig::new("job") + .with_scheduling(SchedulingMode::concurrent_with_pools(one, two, one)), + ScrapeConfig::new("job") + .with_scheduling(SchedulingMode::concurrent_with_pools(one, one, two)), + ]; + for (idx, cfg) in invalid_cases.iter().enumerate() { + assert!( + matches!(cfg.validate(), Err(ScrapeError::InvalidConfig(_))), + "case {idx} unexpectedly passed validate: {cfg:?}" + ); + } + } +} diff --git a/crates/scrape-manager/src/scrape/http.rs b/crates/scrape-manager/src/scrape/http.rs new file mode 100644 index 0000000..0ac2be2 --- /dev/null +++ b/crates/scrape-manager/src/scrape/http.rs @@ -0,0 +1,891 @@ +// Copyright 2026 The Prometheus Authors +// 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 std::future::Future; +use std::io::{self, BufRead, Read}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use flate2::bufread::GzDecoder; +#[cfg(feature = "arrow")] +use prometheus_expfmt::arrow::DecodeError; +use prometheus_expfmt::format::{EscapingScheme, FMT_TEXT, Format, FormatType, response_format}; +use reqwest::header::{ + ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT, +}; +use tokio::time::Instant; + +use crate::scrape::{BodyBufferPool, PooledBodyBuffer, ScrapeConfig, ScrapeTarget}; + +const SCRAPE_TIMEOUT_HEADER: &str = "X-Prometheus-Scrape-Timeout-Seconds"; + +/// Errors produced while validating configuration, fetching HTTP payloads, or decoding scrapes. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ScrapeError { + /// Invalid scrape pool or target configuration. + #[error("invalid scrape configuration: {0}")] + InvalidConfig(String), + /// Transport or request construction failure from `reqwest` (with any URL password stripped). + #[error("HTTP scrape request failed: {0}")] + Http(#[source] reqwest::Error), + /// Target returned a non-200 HTTP status code. + #[error("target returned non-200 HTTP status {status}")] + UnexpectedStatus { + /// HTTP status code returned by the target. + status: u16, + }, + /// Uncompressed response body exceeded [`ScrapeConfig::max_body_bytes`]. + #[error("scrape response body exceeds max_body_bytes ({limit_bytes} bytes)")] + BodyTooLarge { + /// Configured byte cap that was exceeded. + limit_bytes: usize, + }, + /// Malformed `gzip` stream on a compressed target response. + #[error("failed to decompress gzip scrape response: {0}")] + Decompress(String), + /// Unsupported `Content-Encoding` returned by target. + #[error("unsupported scrape Content-Encoding {encoding:?}")] + UnsupportedEncoding { + /// Raw `Content-Encoding` header value. + encoding: String, + }, + /// Scrape exceeded [`ScrapeConfig::scrape_timeout`]. + #[error("scrape timed out after {timeout:?}")] + TimedOut { + /// Configured per-scrape timeout. + timeout: Duration, + }, + /// Exposition payload failed syntax or Arrow decoding. + #[cfg(feature = "arrow")] + #[error("failed to decode exposition payload: {0}")] + Decode(#[from] DecodeError), +} + +impl From for ScrapeError { + fn from(mut err: reqwest::Error) -> Self { + // Strip basic-auth passwords from `reqwest::Error`'s embedded URL so error strings and + // `ScrapeReport::error` never leak credentials. + if let Some(url) = err.url_mut() + && url.password().is_some() + { + let _ = url.set_password(None); + } + Self::Http(err) + } +} + +/// Completed HTTP target fetch holding a pooled, uncompressed exposition buffer and timing metadata. +#[derive(Debug)] +pub struct FetchedScrape { + /// Uncompressed exposition bytes stored in a pooled reusable `Vec`. + pub body: PooledBodyBuffer, + /// Negotiated exposition wire [`Format`]. + pub format: Format, + /// Unix epoch timestamp in milliseconds recorded immediately after the body finished reading. + pub scrape_timestamp_ms: i64, + /// End-to-end HTTP request + body read wall-clock duration. + pub fetch_duration: Duration, + /// Compressed (wire) byte count read from the HTTP socket. + pub wire_bytes: usize, +} + +impl FetchedScrape { + /// Uncompressed exposition payload length in bytes. + #[must_use] + pub fn uncompressed_bytes(&self) -> usize { + self.body.len() + } +} + +/// Asynchronous single-target HTTP or synthetic fetcher used by the scrape scheduler. +/// +/// Intentionally omits a `Send` bound on the returned future so thread-per-core `!Send` runtimes +/// (such as Tokio `LocalSet` workers in `otel-arrow`) can drive custom or local fetchers without +/// synchronization overhead. +pub trait TargetFetcher { + /// Fetches `target`, acquiring a [`PooledBodyBuffer`] from `pool` only once payload bytes are + /// ready to be written. + fn fetch( + &self, + target: &ScrapeTarget, + pool: &BodyBufferPool, + ) -> impl Future>; +} + +/// Builds a Prometheus-compatible `Accept` header value for text and OpenMetrics scraping. +/// +/// Mirrors Prometheus `scrape.acceptHeader`: appends `;escaping=` exclusively to `1.0.0` +/// protocols (`application/openmetrics-text;version=1.0.0` and `text/plain;version=1.0.0`) and +/// omits it on legacy `0.0.1` and `0.0.4` media types. +#[must_use] +pub fn build_accept_header(enable_open_metrics: bool, escaping: EscapingScheme) -> String { + let esc = escaping.as_str(); + if enable_open_metrics { + format!( + "application/openmetrics-text;version=1.0.0;escaping={esc};q=0.7,\ + application/openmetrics-text;version=0.0.1;q=0.6,\ + text/plain;version=1.0.0;escaping={esc};q=0.5,\ + text/plain;version=0.0.4;q=0.4,\ + */*;q=0.3" + ) + } else { + format!( + "text/plain;version=1.0.0;escaping={esc};q=0.7,\ + text/plain;version=0.0.4;q=0.6,\ + */*;q=0.5" + ) + } +} + +/// Formats `timeout` in seconds for the `X-Prometheus-Scrape-Timeout-Seconds` request header, +/// trimming trailing fractional zeros to match Go `strconv.FormatFloat(..., 'f', -1, 64)`. +#[must_use] +pub fn format_scrape_timeout_seconds(timeout: Duration) -> String { + let secs = timeout.as_secs(); + let nanos = timeout.subsec_nanos(); + if nanos == 0 { + return secs.to_string(); + } + let mut frac = format!("{nanos:09}"); + let trimmed_len = frac.trim_end_matches('0').len(); + frac.truncate(trimmed_len); + format!("{secs}.{frac}") +} + +/// Resolves a response `Content-Type` header into an [`expfmt::format::Format`], falling back to +/// Prometheus classic text (`text/plain; version=0.0.4; charset=utf-8`) when `Content-Type` is +/// omitted or unrecognized, and supplying `charset=utf-8` when omitted on OpenMetrics 1.0/0.0.1. +#[must_use] +pub fn resolve_scrape_format(content_type: Option<&str>) -> Format { + let Some(raw) = content_type.map(str::trim).filter(|s| !s.is_empty()) else { + return Format::from(FMT_TEXT); + }; + let parsed = response_format(raw); + if parsed.format_type() != FormatType::Unknown { + return parsed; + } + + // Non-compliant OpenMetrics exporters occasionally omit `charset=utf-8`; retry with it before + // falling back to Prometheus text `0.0.4`. + let lower = raw.to_ascii_lowercase(); + if lower.starts_with("application/openmetrics-text") && !lower.contains("charset=") { + let with_charset = format!("{raw}; charset=utf-8"); + let retried = response_format(&with_charset); + if retried.format_type() != FormatType::Unknown { + return retried; + } + } + + Format::from(FMT_TEXT) +} + +/// Production [`TargetFetcher`] backed by a shared [`reqwest::Client`]. +#[derive(Clone, Debug)] +pub struct ReqwestFetcher { + client: reqwest::Client, + accept_header: HeaderValue, + timeout_header: HeaderValue, + user_agent: HeaderValue, + scrape_timeout: Duration, + enable_compression: bool, + max_body_bytes: usize, +} + +impl ReqwestFetcher { + /// Builds a new [`ReqwestFetcher`] with a default `reqwest::Client` configured from `config`. + /// + /// # Errors + /// + /// Returns [`ScrapeError`] if `config` fails validation or the HTTP client cannot be built. + pub fn new(config: &ScrapeConfig) -> Result { + config.validate()?; + // Disable automatic `reqwest` response decompression even if downstream crates enable + // `reqwest` compression features via Cargo unification, keeping `wire_bytes` and + // `max_body_bytes` enforcement exact. + let client = reqwest::Client::builder() + .no_gzip() + .no_brotli() + .no_zstd() + .no_deflate() + .build()?; + Self::with_client(client, config) + } + + /// Wraps a caller-supplied [`reqwest::Client`] (for example, one built from custom TLS or + /// proxy settings) with scrape request headers and limits from `config`. + /// + /// Caller-built clients should disable automatic response decompression (`.no_gzip()`, + /// `.no_brotli()`, `.no_zstd()`, `.no_deflate()`) so [`ReqwestFetcher`] can enforce + /// [`ScrapeConfig::max_body_bytes`] and measure [`FetchedScrape::wire_bytes`] directly. + /// + /// # Errors + /// + /// Returns [`ScrapeError::InvalidConfig`] if `config` fails validation. + pub fn with_client( + client: reqwest::Client, + config: &ScrapeConfig, + ) -> Result { + config.validate()?; + let accept_raw = build_accept_header(config.enable_open_metrics, config.escaping_scheme); + let accept_header = HeaderValue::from_str(&accept_raw) + .map_err(|err| ScrapeError::InvalidConfig(format!("invalid Accept header: {err}")))?; + let timeout_raw = format_scrape_timeout_seconds(config.scrape_timeout); + let timeout_header = HeaderValue::from_str(&timeout_raw) + .map_err(|err| ScrapeError::InvalidConfig(format!("invalid timeout header: {err}")))?; + let user_agent = HeaderValue::from_str(&config.user_agent).map_err(|err| { + ScrapeError::InvalidConfig(format!("invalid user_agent header: {err}")) + })?; + Ok(Self { + client, + accept_header, + timeout_header, + user_agent, + scrape_timeout: config.scrape_timeout, + enable_compression: config.enable_compression, + max_body_bytes: config.effective_max_body_bytes(), + }) + } +} + +impl TargetFetcher for ReqwestFetcher { + async fn fetch( + &self, + target: &ScrapeTarget, + pool: &BodyBufferPool, + ) -> Result { + let started = Instant::now(); + let timeout = self.scrape_timeout; + + let accept_encoding = if self.enable_compression { + HeaderValue::from_static("gzip") + } else { + HeaderValue::from_static("identity") + }; + let req = self + .client + .get(target.url().clone()) + .header(ACCEPT, self.accept_header.clone()) + .header(ACCEPT_ENCODING, accept_encoding) + .header(SCRAPE_TIMEOUT_HEADER, self.timeout_header.clone()) + .header(USER_AGENT, self.user_agent.clone()); + + // NOTE: Do not lease a `PooledBodyBuffer` while awaiting response headers. Slow targets + // that stall on TCP connect or server-side metric collection hold zero payload memory. + let mut response = tokio::time::timeout(timeout, req.send()) + .await + .map_err(|_| ScrapeError::TimedOut { timeout })? + .map_err(|err| map_reqwest_error(err, timeout))?; + + let status = response.status(); + if status != reqwest::StatusCode::OK { + return Err(ScrapeError::UnexpectedStatus { + status: status.as_u16(), + }); + } + + let encoding = classify_content_encoding(response.headers(), self.enable_compression)?; + + let content_length = response.content_length(); + if matches!(encoding, BodyEncoding::Identity) + && let Some(len) = content_length + && len > u64::try_from(self.max_body_bytes).unwrap_or(u64::MAX) + { + return Err(ScrapeError::BodyTooLarge { + limit_bytes: self.max_body_bytes, + }); + } + + let format = resolve_scrape_format( + response + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + ); + + let cap_hint = match (encoding, content_length) { + (BodyEncoding::Identity, Some(len)) => usize::try_from(len) + .unwrap_or(0) + .min(self.max_body_bytes) + .min(256 * 1024), + _ => 0, + }; + + let header_elapsed = started.elapsed(); + let remaining = timeout.saturating_sub(header_elapsed); + if remaining.is_zero() { + return Err(ScrapeError::TimedOut { timeout }); + } + + // NOTE: Acquire the `PooledBodyBuffer` outside the `scrape_timeout` window so downstream + // sink or pool backpressure never marks healthy HTTP targets as `TimedOut` (`up = false`). + let mut body = pool.acquire(cap_hint).await; + let body_started = Instant::now(); + + let wire_bytes = tokio::time::timeout(remaining, async { + let mut wire_bytes = 0usize; + + match encoding { + BodyEncoding::Identity => { + while let Some(chunk) = response + .chunk() + .await + .map_err(|err| map_reqwest_error(err, timeout))? + { + wire_bytes = wire_bytes.saturating_add(chunk.len()); + if body.len().saturating_add(chunk.len()) > self.max_body_bytes { + return Err(ScrapeError::BodyTooLarge { + limit_bytes: self.max_body_bytes, + }); + } + body.extend_from_slice(&chunk); + } + } + BodyEncoding::Gzip => { + let (out_buf, gzip_slot) = body.parts_mut(); + let decoder = + gzip_slot.get_or_insert_with(|| Box::new(PooledGzipDecoder::new())); + decoder.reset_for_scrape(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|err| map_reqwest_error(err, timeout))? + { + wire_bytes = wire_bytes.saturating_add(chunk.len()); + decoder.push_chunk(&chunk, out_buf, self.max_body_bytes)?; + } + decoder.finish(out_buf, self.max_body_bytes)?; + } + } + + Ok::(wire_bytes) + }) + .await + .map_err(|_| ScrapeError::TimedOut { timeout })??; + + let scrape_timestamp_ms = current_timestamp_ms(); + let fetch_duration = header_elapsed.saturating_add(body_started.elapsed()); + + Ok(FetchedScrape { + body, + format, + scrape_timestamp_ms, + fetch_duration, + wire_bytes, + }) + } +} + +fn map_reqwest_error(err: reqwest::Error, timeout: Duration) -> ScrapeError { + if err.is_timeout() { + ScrapeError::TimedOut { timeout } + } else { + ScrapeError::from(err) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BodyEncoding { + Identity, + Gzip, +} + +fn classify_content_encoding( + headers: &HeaderMap, + enable_compression: bool, +) -> Result { + let mut resolved = BodyEncoding::Identity; + for raw_header in headers.get_all(CONTENT_ENCODING) { + let Ok(raw_str) = raw_header.to_str() else { + return Err(ScrapeError::UnsupportedEncoding { + encoding: String::from_utf8_lossy(raw_header.as_bytes()).into_owned(), + }); + }; + for token in raw_str.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if token.eq_ignore_ascii_case("identity") { + continue; + } + if (token.eq_ignore_ascii_case("gzip") || token.eq_ignore_ascii_case("x-gzip")) + && enable_compression + && matches!(resolved, BodyEncoding::Identity) + { + resolved = BodyEncoding::Gzip; + } else { + return Err(ScrapeError::UnsupportedEncoding { + encoding: raw_str.trim().to_owned(), + }); + } + } + } + Ok(resolved) +} + +#[derive(Debug, Default)] +struct ChunkFeed { + buf: Vec, + pos: usize, + eof: bool, +} + +impl ChunkFeed { + fn clear(&mut self) { + self.buf.clear(); + self.pos = 0; + self.eof = false; + } + + fn push_bytes(&mut self, bytes: &[u8]) { + if self.pos >= self.buf.len() { + self.buf.clear(); + self.pos = 0; + } else if self.pos > 0 { + self.buf.drain(..self.pos); + self.pos = 0; + } + self.buf.extend_from_slice(bytes); + } + + fn is_empty(&self) -> bool { + self.pos >= self.buf.len() + } +} + +impl Read for ChunkFeed { + fn read(&mut self, dst: &mut [u8]) -> io::Result { + let avail = self.fill_buf()?; + let n = avail.len().min(dst.len()); + dst[..n].copy_from_slice(&avail[..n]); + self.consume(n); + Ok(n) + } +} + +impl BufRead for ChunkFeed { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + if self.pos < self.buf.len() { + Ok(&self.buf[self.pos..]) + } else if self.eof { + Ok(&[]) + } else { + Err(io::Error::from(io::ErrorKind::WouldBlock)) + } + } + + fn consume(&mut self, amt: usize) { + self.pos = (self.pos + amt).min(self.buf.len()); + if self.pos == self.buf.len() { + self.buf.clear(); + self.pos = 0; + } + } +} + +/// Reusable streaming gzip inflate state parked alongside a [`PooledBodyBuffer`] so repeated +/// compressed scrapes avoid reallocating `miniz_oxide`'s ~40 KiB dictionary and 32 KiB buffer. +#[derive(Debug)] +pub(crate) struct PooledGzipDecoder { + decoder: GzDecoder, + member_started: bool, + member_finished: bool, +} + +impl PooledGzipDecoder { + pub(crate) fn new() -> Self { + Self { + decoder: GzDecoder::new(ChunkFeed::default()), + member_started: false, + member_finished: false, + } + } + + pub(crate) fn reset_for_scrape(&mut self) { + let mut feed = self.decoder.reset(ChunkFeed::default()); + feed.clear(); + let _ = self.decoder.reset(feed); + self.member_started = false; + self.member_finished = false; + } + + fn reset_next_member(&mut self) { + let feed = self.decoder.reset(ChunkFeed::default()); + let _ = self.decoder.reset(feed); + self.member_finished = false; + } + + pub(crate) fn shrink_retained(&mut self, max_retained_bytes: usize) { + let cap = max_retained_bytes.min(64 * 1024); + let feed = self.decoder.get_mut(); + feed.clear(); + if feed.buf.capacity() > cap { + feed.buf.shrink_to(cap); + } + } + + fn push_chunk( + &mut self, + chunk: &[u8], + out: &mut Vec, + limit: usize, + ) -> Result<(), ScrapeError> { + if chunk.is_empty() { + return Ok(()); + } + if self.member_finished { + self.reset_next_member(); + } + self.member_started = true; + self.decoder.get_mut().push_bytes(chunk); + self.drain_into(out, limit) + } + + fn finish(&mut self, out: &mut Vec, limit: usize) -> Result<(), ScrapeError> { + self.decoder.get_mut().eof = true; + if self.member_finished && self.decoder.get_ref().is_empty() { + return Ok(()); + } + self.drain_into(out, limit) + } + + fn drain_into(&mut self, out: &mut Vec, limit: usize) -> Result<(), ScrapeError> { + let mut scratch = [0_u8; 8_192]; + loop { + match self.decoder.read(&mut scratch) { + Ok(0) => { + if !self.decoder.get_ref().is_empty() { + // RFC 1952 §2.2 allows concatenating multiple gzip members in one stream. + self.reset_next_member(); + continue; + } + self.member_finished = true; + return Ok(()); + } + Ok(n) => { + if out.len().saturating_add(n) > limit { + return Err(ScrapeError::BodyTooLarge { limit_bytes: limit }); + } + out.extend_from_slice(&scratch[..n]); + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(err) => return Err(ScrapeError::Decompress(err.to_string())), + } + } + } +} + +pub(crate) fn current_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|d| i64::try_from(d.as_millis()).ok()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + use std::num::NonZeroUsize; + use std::time::Duration; + + use flate2::Compression; + use flate2::write::GzEncoder; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + + #[test] + fn timeout_header_formatting_matches_prometheus() { + assert_eq!(format_scrape_timeout_seconds(Duration::from_secs(10)), "10"); + assert_eq!( + format_scrape_timeout_seconds(Duration::from_millis(2_500)), + "2.5" + ); + assert_eq!( + format_scrape_timeout_seconds(Duration::from_millis(125)), + "0.125" + ); + } + + #[test] + fn accept_header_respects_openmetrics_and_escaping() { + let om = build_accept_header(true, EscapingScheme::EscapeUnderscores); + assert_eq!( + om, + "application/openmetrics-text;version=1.0.0;escaping=underscores;q=0.7,\ + application/openmetrics-text;version=0.0.1;q=0.6,\ + text/plain;version=1.0.0;escaping=underscores;q=0.5,\ + text/plain;version=0.0.4;q=0.4,\ + */*;q=0.3" + ); + + let utf8 = build_accept_header(false, EscapingScheme::AllowUtf8); + assert_eq!( + utf8, + "text/plain;version=1.0.0;escaping=allow-utf-8;q=0.7,\ + text/plain;version=0.0.4;q=0.6,\ + */*;q=0.5" + ); + } + + #[test] + fn resolve_format_handles_missing_and_bare_openmetrics_headers() { + assert_eq!( + resolve_scrape_format(None).format_type(), + FormatType::TextPlain + ); + assert_eq!( + resolve_scrape_format(Some("application/openmetrics-text; version=1.0.0")) + .format_type(), + FormatType::OpenMetrics + ); + } + + #[tokio::test] + async fn reqwest_fetcher_handles_gzip_headers_and_gzip_bomb_limit() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + + let server = tokio::spawn(async move { + // 1. Valid gzip-compressed OpenMetrics payload; verify request headers. + let (mut sock, _) = listener.accept().await.expect("accept 1"); + let mut req_buf = vec![0u8; 2048]; + let n = sock.read(&mut req_buf).await.expect("read 1"); + let req_str = String::from_utf8_lossy(&req_buf[..n]).to_ascii_lowercase(); + assert!(req_str.contains("accept-encoding: gzip")); + assert!(req_str.contains("x-prometheus-scrape-timeout-seconds: 2.5")); + + let payload = b"# TYPE demo_requests_total counter\n\ +demo_requests_total{instance=\"a\",method=\"GET\"} 10\n\ +demo_requests_total{instance=\"a\",method=\"POST\"} 20\n\ +demo_requests_total{instance=\"b\",method=\"GET\"} 30\n\ +demo_requests_total{instance=\"b\",method=\"POST\"} 40\n\ +# EOF\n"; + let mut gz = GzEncoder::new(Vec::new(), Compression::fast()); + gz.write_all(payload).expect("gz write"); + let compressed = gz.finish().expect("gz finish"); + + let hdr = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/openmetrics-text; version=1.0.0\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + compressed.len() + ); + sock.write_all(hdr.as_bytes()).await.expect("write hdr 1"); + sock.write_all(&compressed).await.expect("write body 1"); + + // 2. Oversized compressed payload (16 KiB of zeros -> tiny gzip, exceeds 1 KiB limit). + let (mut sock2, _) = listener.accept().await.expect("accept 2"); + let _ = sock2.read(&mut req_buf).await.expect("read 2"); + let bomb = vec![b'a'; 16 * 1024]; + let mut gz2 = GzEncoder::new(Vec::new(), Compression::best()); + gz2.write_all(&bomb).expect("gz2 write"); + let compressed_bomb = gz2.finish().expect("gz2 finish"); + assert!(compressed_bomb.len() < 256); + + let hdr2 = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + compressed_bomb.len() + ); + sock2.write_all(hdr2.as_bytes()).await.expect("write hdr 2"); + sock2 + .write_all(&compressed_bomb) + .await + .expect("write body 2"); + }); + + let cfg = ScrapeConfig::new("test") + .with_scrape_interval(Duration::from_secs(5)) + .with_scrape_timeout(Duration::from_millis(2_500)) + .with_max_body_bytes(1_024) + .with_max_retained_body_bytes(512); + let fetcher = ReqwestFetcher::new(&cfg).expect("fetcher"); + let pool = BodyBufferPool::new(NonZeroUsize::MIN, cfg.max_retained_body_bytes); + let target = ScrapeTarget::parse(&format!("http://{addr}/metrics")).expect("target"); + + let ok = fetcher.fetch(&target, &pool).await.expect("first fetch ok"); + assert_eq!(ok.format.format_type(), FormatType::OpenMetrics); + assert!( + ok.body + .as_slice() + .starts_with(b"# TYPE demo_requests_total counter\n") + ); + assert!(ok.wire_bytes < ok.uncompressed_bytes()); + drop(ok); + assert_eq!(pool.total_buffers_created(), 1); + assert!(pool.retained_memory_bytes() <= 512); + + let err = fetcher + .fetch(&target, &pool) + .await + .expect_err("gzip bomb must exceed max_body_bytes"); + assert!(matches!( + err, + ScrapeError::BodyTooLarge { limit_bytes: 1_024 } + )); + assert_eq!(pool.total_buffers_created(), 1); + assert!(pool.retained_memory_bytes() <= 512); + + server.await.expect("server join"); + } + + #[tokio::test] + async fn body_buffer_pool_wait_does_not_count_against_scrape_timeout() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.expect("accept"); + let mut req_buf = [0u8; 1024]; + let _ = sock.read(&mut req_buf).await.expect("read"); + let body = b"up_metric 1\n"; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + sock.write_all(resp.as_bytes()).await.expect("write hdr"); + sock.write_all(body).await.expect("write body"); + }); + + let cfg = ScrapeConfig::new("pool-wait") + .with_scrape_interval(Duration::from_secs(5)) + .with_scrape_timeout(Duration::from_millis(80)); + let fetcher = ReqwestFetcher::new(&cfg).expect("fetcher"); + let pool = BodyBufferPool::new(NonZeroUsize::MIN, cfg.max_retained_body_bytes); + let target = ScrapeTarget::parse(&format!("http://{addr}/metrics")).expect("target"); + + // Hold the sole buffer permit longer than `scrape_timeout` (160ms > 80ms). + let held = pool.acquire(0).await; + let release = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(160)).await; + drop(held); + }); + + let fetched = fetcher + .fetch(&target, &pool) + .await + .expect("pool wait must not trigger TimedOut on fast target"); + assert_eq!(fetched.body.as_slice(), b"up_metric 1\n"); + assert!(fetched.fetch_duration < Duration::from_millis(80)); + drop(fetched); + + release.await.expect("release join"); + server.await.expect("server join"); + } + + #[test] + fn pooled_gzip_decoder_decodes_concatenated_multi_member_streams() { + let mut member1 = GzEncoder::new(Vec::new(), Compression::fast()); + member1.write_all(b"metric_one 1\n").expect("m1 write"); + let mut combined = member1.finish().expect("m1 finish"); + + let mut member2 = GzEncoder::new(Vec::new(), Compression::default()); + member2.write_all(b"metric_two 2\n").expect("m2 write"); + combined.extend_from_slice(&member2.finish().expect("m2 finish")); + + let mut dec = PooledGzipDecoder::new(); + for chunk_size in [combined.len(), 7, 1] { + dec.reset_for_scrape(); + let mut out = Vec::new(); + for chunk in combined.chunks(chunk_size) { + dec.push_chunk(chunk, &mut out, 1024).expect("push chunk"); + } + dec.finish(&mut out, 1024).expect("finish"); + assert_eq!(out, b"metric_one 1\nmetric_two 2\n"); + } + } + + #[tokio::test] + async fn reqwest_fetcher_rejects_non_200_bad_encoding_large_content_length_and_timeouts() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let server = tokio::spawn(async move { + let mut req_buf = [0u8; 2048]; + + // 1. 204 No Content -> UnexpectedStatus { status: 204 } (no buffer allocated). + let (mut s1, _) = listener.accept().await.expect("accept 1"); + let n1 = s1.read(&mut req_buf).await.expect("read 1"); + let req1 = String::from_utf8_lossy(&req_buf[..n1]).to_ascii_lowercase(); + assert!(req1.contains("accept-encoding: identity")); + s1.write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .expect("write 1"); + + // 2. Unsolicited gzip when `enable_compression = false` -> UnsupportedEncoding. + let (mut s2, _) = listener.accept().await.expect("accept 2"); + let _ = s2.read(&mut req_buf).await.expect("read 2"); + s2.write_all( + b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .expect("write 2"); + + // 3. Uncompressed Content-Length > max_body_bytes -> fast BodyTooLarge (0 buffers allocated). + let (mut s3, _) = listener.accept().await.expect("accept 3"); + let _ = s3.read(&mut req_buf).await.expect("read 3"); + s3.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4096\r\nConnection: close\r\n\r\n") + .await + .expect("write 3"); + + // 4. Stalled headers -> TimedOut, and basic-auth password must not appear in error text. + let (mut s4, _) = listener.accept().await.expect("accept 4"); + let _ = s4.read(&mut req_buf).await.expect("read 4"); + tokio::time::sleep(Duration::from_millis(180)).await; + }); + + let cfg = ScrapeConfig::new("errors") + .with_scrape_interval(Duration::from_secs(5)) + .with_scrape_timeout(Duration::from_millis(60)) + .with_enable_compression(false) + .with_max_body_bytes(512); + let fetcher = ReqwestFetcher::new(&cfg).expect("fetcher"); + let pool = BodyBufferPool::new(NonZeroUsize::MIN, 512); + let target = ScrapeTarget::parse(&format!("http://{addr}/metrics")).expect("target"); + + let e1 = fetcher.fetch(&target, &pool).await.expect_err("204 fails"); + assert!(matches!(e1, ScrapeError::UnexpectedStatus { status: 204 })); + assert_eq!(pool.total_buffers_created(), 0); + + let e2 = fetcher + .fetch(&target, &pool) + .await + .expect_err("unsolicited gzip fails"); + assert!(matches!(e2, ScrapeError::UnsupportedEncoding { .. })); + assert_eq!(pool.total_buffers_created(), 0); + + let e3 = fetcher + .fetch(&target, &pool) + .await + .expect_err("oversized Content-Length fails before buffer lease"); + assert!(matches!(e3, ScrapeError::BodyTooLarge { limit_bytes: 512 })); + assert_eq!(pool.total_buffers_created(), 0); + + let e4 = fetcher + .fetch(&target, &pool) + .await + .expect_err("stalled headers time out"); + assert!(matches!(e4, ScrapeError::TimedOut { .. })); + assert_eq!(pool.active_leases(), 0); + + server.await.expect("server join"); + + // Closed loopback port with basic-auth credentials must strip password from ScrapeError. + let closed_target = + ScrapeTarget::parse("http://admin:super_secret_pw@127.0.0.1:1/metrics").unwrap(); + let conn_err = fetcher + .fetch(&closed_target, &pool) + .await + .expect_err("closed port fails"); + assert!( + !conn_err.to_string().contains("super_secret_pw"), + "password leaked in ScrapeError: {conn_err}" + ); + } +} diff --git a/crates/scrape-manager/src/scrape/manager.rs b/crates/scrape-manager/src/scrape/manager.rs new file mode 100644 index 0000000..433dd73 --- /dev/null +++ b/crates/scrape-manager/src/scrape/manager.rs @@ -0,0 +1,873 @@ +// Copyright 2026 The Prometheus Authors +// 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 std::future::Future; +use std::ops::ControlFlow; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +#[cfg(feature = "arrow")] +use std::time::Instant as StdInstant; + +use futures_util::StreamExt; +use futures_util::stream::FuturesUnordered; +#[cfg(feature = "arrow")] +use prometheus_expfmt::arrow::{DecodeBudget, DecodeCursor, DecodeStep, OtapMetricRecords}; +use tokio::sync::mpsc; +use tokio::time::{Instant, sleep_until}; + +#[cfg(feature = "arrow")] +use crate::scrape::DecoderPool; +#[cfg(feature = "arrow")] +use crate::scrape::http::current_timestamp_ms; +use crate::scrape::schedule::{TargetSchedule, advance_grid_deadline}; +use crate::scrape::{ + BodyBufferPool, FetchedScrape, ReqwestFetcher, SchedulingMode, ScrapeConfig, ScrapeError, + ScrapeTarget, SequentialSchedule, TargetFetcher, +}; + +/// Control-plane messages accepted by a running [`ScrapeManager`]. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum ScrapeCommand { + /// Replaces the active target set while preserving the interval phase of unchanged targets. + UpdateTargets(Vec), + /// Stops scheduling new scrapes and exits the run loop cleanly. + Shutdown, +} + +/// Handle used to push target discovery updates or request shutdown on a [`ScrapeManager`]. +#[derive(Clone, Debug)] +pub struct ScrapeControl { + tx: mpsc::UnboundedSender, +} + +impl ScrapeControl { + /// Creates an unbounded control channel pair `(`[`ScrapeControl`]`, Receiver)` for live target + /// updates and graceful shutdown. + #[must_use] + pub fn channel() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self { tx }, rx) + } + + /// Sends a new target snapshot to the running [`ScrapeManager`]. + /// + /// Returns `false` if the manager loop has already exited. + #[must_use] + pub fn update_targets(&self, targets: Vec) -> bool { + self.tx.send(ScrapeCommand::UpdateTargets(targets)).is_ok() + } + + /// Signals the running [`ScrapeManager`] to stop and return. + /// + /// Returns `false` if the manager loop has already exited. + #[must_use] + pub fn shutdown(&self) -> bool { + self.tx.send(ScrapeCommand::Shutdown).is_ok() + } +} + +/// Per-target outcome summary emitted at the end of every scrape attempt. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct ScrapeReport { + /// `true` when HTTP fetch and payload decoding succeeded (`up == 1`). + pub up: bool, + /// Unix epoch timestamp in milliseconds recorded for the scrape. + pub scrape_timestamp_ms: i64, + /// End-to-end fetch + decode wall-clock duration. + pub duration: Duration, + /// Compressed (wire) bytes read from the target socket. + pub wire_bytes: usize, + /// Uncompressed exposition payload bytes. + pub uncompressed_bytes: usize, + /// Total metric data points delivered to [`ArrowScrapeSink::on_batch`] during this scrape + /// (`0` on raw scrapes or when failing before the first batch). + pub data_points_decoded: u64, + /// Number of Arrow chunks delivered to [`ArrowScrapeSink::on_batch`] during this scrape + /// (`0` on empty/raw scrapes or when failing before the first batch). + pub batches_emitted: usize, + /// Error description when `up == false`. + pub error: Option, +} + +/// Point-in-time counter and pool memory snapshot from [`ScrapeManager::stats`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct ManagerStats { + /// Total target scrape attempts started. + pub scrapes_started: u64, + /// Total target scrapes that completed HTTP fetch and decode without error. + pub scrapes_succeeded: u64, + /// Total target scrapes that failed HTTP fetch, size limits, or decoding. + pub scrapes_failed: u64, + /// Missed interval ticks caused by slow cycles or targets still in flight at their next tick. + pub scrapes_overrun: u64, + /// Total metric data points decoded across all succeeded Arrow scrapes. + pub data_points_decoded: u64, + /// Maximum number of response body buffers leased simultaneously. + pub peak_buffered_bodies: usize, + /// Total distinct `Vec` body buffers ever allocated by the pool. + pub total_body_buffers_created: usize, + /// Idle `Vec` buffer capacity currently retained in bytes. + pub retained_body_buffer_bytes: usize, + /// Maximum number of `ArrowDecoder` instances leased simultaneously. + #[cfg(feature = "arrow")] + pub peak_active_decoders: usize, + /// Total distinct `ArrowDecoder` instances ever allocated by the pool. + #[cfg(feature = "arrow")] + pub total_decoders_created: usize, + /// Idle `ArrowDecoder` scratch capacity currently retained in bytes. + #[cfg(feature = "arrow")] + pub retained_decoder_bytes: usize, +} + +#[derive(Debug, Default)] +struct AtomicCounters { + started: AtomicU64, + succeeded: AtomicU64, + failed: AtomicU64, + overrun: AtomicU64, + points_decoded: AtomicU64, +} + +/// Consumer receiving raw uncompressed target payloads without Arrow decoding. +pub trait RawScrapeSink { + /// Handles a completed or failed target fetch, returning [`ControlFlow::Break`] to stop the + /// manager loop early. + /// + /// Note that `Ok(fetched)` owns a [`crate::scrape::PooledBodyBuffer`] permit from + /// [`crate::scrape::BodyBufferPool`] until `fetched` is dropped; drop `fetched` before + /// awaiting slow downstream I/O to avoid stalling concurrent scrapes. + fn on_raw_scrape( + &self, + target: &ScrapeTarget, + result: Result, + ) -> impl Future>; +} + +/// Consumer receiving streaming [`OtapMetricRecords`] chunks and per-scrape completion reports. +/// +/// Intentionally omits `Send` bounds so single-threaded `!Send` runtimes (such as Tokio +/// `LocalSet` workers) can pass local channels or `Rc`/`RefCell` state directly. +/// +/// # Transactional scrape atomicity and pool permits +/// +/// Because [`ArrowScrapeSink::on_batch`] streams decoded chunks incrementally to bound scratch +/// memory, a multi-chunk scrape can fail on a later chunk (for example, on malformed trailing +/// syntax or a missing OpenMetrics `# EOF`). Chunks delivered to `on_batch` are therefore +/// tentative until [`ArrowScrapeSink::on_scrape_complete`] observes `report.up == true`. Sinks +/// that require transactional Prometheus scrape semantics (matching Go `app.Commit()` / +/// `app.Rollback()`) should stage per-scrape batches and discard the `report.batches_emitted` +/// tentative chunks whenever `report.up == false`. +#[cfg(feature = "arrow")] +pub trait ArrowScrapeSink { + /// Receives one bounded [`OtapMetricRecords`] chunk decoded from `target`. + /// + /// On single-chunk scrapes (and on the final chunk of multi-chunk scrapes), both the + /// [`crate::scrape::PooledBodyBuffer`] and [`crate::scrape::PooledDecoder`] permits are + /// released back to their pools before `on_batch` is awaited. On intermediate chunks of a + /// multi-chunk scrape, awaiting inside `on_batch` holds both permits until decoding resumes; + /// size `max_buffered_bodies` and `max_decoders` via + /// [`SchedulingMode::concurrent_with_pools`] when sinks apply async backpressure on large + /// multi-chunk targets. Returning [`ControlFlow::Break`] aborts the active scrape, drops + /// pooled buffers back into their freelists, and stops the manager loop. + fn on_batch( + &self, + target: &ScrapeTarget, + scrape_timestamp_ms: i64, + records: OtapMetricRecords, + ) -> impl Future>; + + /// Receives final outcome telemetry for `target` after all chunks have been delivered and both + /// the body buffer and [`prometheus_expfmt::arrow::ArrowDecoder`] have been returned to their + /// pools. + /// + /// When `report.up == false` and `report.batches_emitted > 0`, decoding failed after earlier + /// chunks were streamed to [`ArrowScrapeSink::on_batch`]; transactional sinks must roll back + /// those tentative batches. + fn on_scrape_complete( + &self, + target: &ScrapeTarget, + report: ScrapeReport, + ) -> impl Future> { + let _ = (target, report); + async { ControlFlow::Continue(()) } + } +} + +/// Target scrape coordinator supporting both [`SchedulingMode::Sequential`] and bounded +/// [`SchedulingMode::Concurrent`] execution on `Send` or `!Send` Tokio runtimes. +#[derive(Debug)] +pub struct ScrapeManager { + config: ScrapeConfig, + fetcher: F, + body_pool: BodyBufferPool, + #[cfg(feature = "arrow")] + decoder_pool: DecoderPool, + counters: AtomicCounters, +} + +impl ScrapeManager { + /// Constructs a [`ScrapeManager`] backed by a default [`ReqwestFetcher`]. + /// + /// # Errors + /// + /// Returns [`ScrapeError`] if `config` fails validation or the HTTP client cannot be built. + pub fn with_reqwest(config: ScrapeConfig) -> Result { + let fetcher = ReqwestFetcher::new(&config)?; + Self::new(config, fetcher) + } + + /// Creates an unbounded control channel pair `(`[`ScrapeControl`]`, Receiver)` for live target + /// updates and graceful shutdown. + #[must_use] + pub fn control_channel() -> (ScrapeControl, mpsc::UnboundedReceiver) { + ScrapeControl::channel() + } +} + +impl ScrapeManager { + /// Constructs a [`ScrapeManager`] with a caller-supplied [`TargetFetcher`]. + /// + /// # Errors + /// + /// Returns [`ScrapeError::InvalidConfig`] if `config` fails validation. + pub fn new(config: ScrapeConfig, fetcher: F) -> Result { + config.validate()?; + let body_pool = BodyBufferPool::new( + config.scheduling.max_buffered_bodies(), + config.max_retained_body_bytes, + ); + #[cfg(feature = "arrow")] + let decoder_pool = DecoderPool::new( + config.scheduling.max_decoders(), + config.max_retained_decoder_bytes, + ); + Ok(Self { + config, + fetcher, + body_pool, + #[cfg(feature = "arrow")] + decoder_pool, + counters: AtomicCounters::default(), + }) + } + + /// Returns the active [`ScrapeConfig`]. + #[must_use] + pub const fn config(&self) -> &ScrapeConfig { + &self.config + } + + /// Returns a snapshot of cumulative scrape counters and scratch pool memory usage. + #[must_use] + pub fn stats(&self) -> ManagerStats { + ManagerStats { + scrapes_started: self.counters.started.load(Ordering::Relaxed), + scrapes_succeeded: self.counters.succeeded.load(Ordering::Relaxed), + scrapes_failed: self.counters.failed.load(Ordering::Relaxed), + scrapes_overrun: self.counters.overrun.load(Ordering::Relaxed), + data_points_decoded: self.counters.points_decoded.load(Ordering::Relaxed), + peak_buffered_bodies: self.body_pool.peak_leases(), + total_body_buffers_created: self.body_pool.total_buffers_created(), + retained_body_buffer_bytes: self.body_pool.retained_memory_bytes(), + #[cfg(feature = "arrow")] + peak_active_decoders: self.decoder_pool.peak_leases(), + #[cfg(feature = "arrow")] + total_decoders_created: self.decoder_pool.total_decoders_created(), + #[cfg(feature = "arrow")] + retained_decoder_bytes: self.decoder_pool.retained_memory_bytes(), + } + } +} + +impl ScrapeManager { + /// Executes a single HTTP fetch of `target` using the bounded [`BodyBufferPool`]. + pub async fn scrape_once_raw( + &self, + target: &ScrapeTarget, + ) -> Result { + self.counters.started.fetch_add(1, Ordering::Relaxed); + match self.fetcher.fetch(target, &self.body_pool).await { + Ok(fetched) => { + self.counters.succeeded.fetch_add(1, Ordering::Relaxed); + Ok(fetched) + } + Err(err) => { + self.counters.failed.fetch_add(1, Ordering::Relaxed); + Err(err) + } + } + } + + /// Executes a single immediate raw scrape pass across `targets` respecting the concurrency and + /// pool limits in [`ScrapeConfig::scheduling`] without sleeping between interval ticks. + pub async fn run_once_raw( + &self, + targets: &[ScrapeTarget], + sink: &S, + ) -> ControlFlow<()> { + self.run_once_with(targets, |target| async move { + let res = self.scrape_once_raw(target).await; + sink.on_raw_scrape(target, res).await + }) + .await + } + + /// Runs continuous raw target scraping according to [`ScrapeConfig::scheduling`] until + /// [`ScrapeCommand::Shutdown`], control channel closure, or [`ControlFlow::Break`] from `sink`. + pub async fn run_raw( + &self, + initial_targets: Vec, + ctrl_rx: mpsc::UnboundedReceiver, + sink: &S, + ) { + self.run_loop(initial_targets, ctrl_rx, |target| async move { + let res = self.scrape_once_raw(&target).await; + sink.on_raw_scrape(&target, res).await + }) + .await; + } + + /// Fetches `target` once, streams cooperatives chunks through a pooled [`ArrowDecoder`], and + /// emits a final [`ScrapeReport`] to `sink`. + #[cfg(feature = "arrow")] + pub async fn scrape_once_arrow( + &self, + target: &ScrapeTarget, + sink: &S, + ) -> ControlFlow<(), ScrapeReport> { + let started = StdInstant::now(); + self.counters.started.fetch_add(1, Ordering::Relaxed); + + let fetched = match self.fetcher.fetch(target, &self.body_pool).await { + Ok(ok) => ok, + Err(err) => { + self.counters.failed.fetch_add(1, Ordering::Relaxed); + let report = ScrapeReport { + up: false, + scrape_timestamp_ms: current_timestamp_ms(), + duration: started.elapsed(), + wire_bytes: 0, + uncompressed_bytes: 0, + data_points_decoded: 0, + batches_emitted: 0, + error: Some(err.to_string()), + }; + if sink + .on_scrape_complete(target, report.clone()) + .await + .is_break() + { + return ControlFlow::Break(()); + } + return ControlFlow::Continue(report); + } + }; + + let wire_bytes = fetched.wire_bytes; + let uncompressed_bytes = fetched.uncompressed_bytes(); + let scrape_timestamp_ms = fetched.scrape_timestamp_ms; + + let decode_outcome = self + .decode_fetched_cooperatively(target, fetched, sink) + .await; + + let report = match decode_outcome { + ControlFlow::Break(()) => return ControlFlow::Break(()), + ControlFlow::Continue((points, batches, Ok(()))) => { + self.counters.succeeded.fetch_add(1, Ordering::Relaxed); + self.counters + .points_decoded + .fetch_add(points, Ordering::Relaxed); + ScrapeReport { + up: true, + scrape_timestamp_ms, + duration: started.elapsed(), + wire_bytes, + uncompressed_bytes, + data_points_decoded: points, + batches_emitted: batches, + error: None, + } + } + ControlFlow::Continue((points, batches, Err(err))) => { + self.counters.failed.fetch_add(1, Ordering::Relaxed); + ScrapeReport { + up: false, + scrape_timestamp_ms, + duration: started.elapsed(), + wire_bytes, + uncompressed_bytes, + data_points_decoded: points, + batches_emitted: batches, + error: Some(err.to_string()), + } + } + }; + + if sink + .on_scrape_complete(target, report.clone()) + .await + .is_break() + { + return ControlFlow::Break(()); + } + ControlFlow::Continue(report) + } + + /// Executes a single immediate scrape pass across `targets` respecting the concurrency and + /// pool limits in [`ScrapeConfig::scheduling`] without sleeping between interval ticks. + #[cfg(feature = "arrow")] + pub async fn run_once_arrow( + &self, + targets: &[ScrapeTarget], + sink: &S, + ) -> ControlFlow<()> { + self.run_once_with(targets, |target| async move { + match self.scrape_once_arrow(target, sink).await { + ControlFlow::Continue(_) => ControlFlow::Continue(()), + ControlFlow::Break(()) => ControlFlow::Break(()), + } + }) + .await + } + + /// Runs continuous Arrow target scraping according to [`ScrapeConfig::scheduling`] until + /// [`ScrapeCommand::Shutdown`], control channel closure, or [`ControlFlow::Break`] from `sink`. + #[cfg(feature = "arrow")] + pub async fn run_arrow( + &self, + initial_targets: Vec, + ctrl_rx: mpsc::UnboundedReceiver, + sink: &S, + ) { + self.run_loop(initial_targets, ctrl_rx, |target| async move { + match self.scrape_once_arrow(&target, sink).await { + ControlFlow::Continue(_) => ControlFlow::Continue(()), + ControlFlow::Break(()) => ControlFlow::Break(()), + } + }) + .await; + } + + #[cfg(feature = "arrow")] + async fn decode_fetched_cooperatively( + &self, + target: &ScrapeTarget, + fetched: FetchedScrape, + sink: &S, + ) -> ControlFlow<(), (u64, usize, Result<(), ScrapeError>)> { + struct ActiveDecodeGuard { + decoder: Option, + cursor: DecodeCursor, + } + + impl ActiveDecodeGuard { + fn release(&mut self) { + if let Some(mut dec) = self.decoder.take() { + dec.finish_decode(&mut self.cursor); + } + } + } + + impl Drop for ActiveDecodeGuard { + fn drop(&mut self) { + self.release(); + } + } + + let scrape_timestamp_ms = fetched.scrape_timestamp_ms; + let format = fetched.format; + let mut body_lease = Some(fetched.body); + let mut decoder = self.decoder_pool.acquire().await; + + let chunk_limit = if self.config.max_data_points_per_batch == 0 { + usize::MAX + } else { + self.config.max_data_points_per_batch + }; + + // TODO(bwplotka): Add and wire `ScrapeConfig::honor_timestamps` once + // `DecodeOptions::with_honor_timestamps` lands in + // https://github.com/prometheus/scrape.rs/issues/33. + let decode_opts = self + .config + .decode_options + .with_timestamp_ms(scrape_timestamp_ms) + .with_max_data_points_per_batch(chunk_limit); + + let cursor = match decoder.start_decode_stream(&format, &decode_opts) { + Ok(c) => c, + Err(err) => { + drop(decoder); + drop(body_lease); + return ControlFlow::Continue((0, 0, Err(ScrapeError::Decode(err)))); + } + }; + let mut guard = ActiveDecodeGuard { + decoder: Some(decoder), + cursor, + }; + + let base_budget = self.config.decode_budget; + let max_entries = base_budget.max_entries.max(1); + let max_bytes = base_budget.max_bytes.max(1); + let max_duration = base_budget.max_duration; + + let mut decoded_points = 0_u64; + let mut batches_emitted = 0_usize; + let mut slice_start_pos = 0_usize; + let mut slice_entries = 0_usize; + let mut slice_started = StdInstant::now(); + + loop { + if guard.cursor.is_done() { + break; + } + + let step_budget = if slice_entries == 0 && guard.cursor.pos() == slice_start_pos { + base_budget + } else { + let remaining_duration = if max_duration.is_zero() { + Duration::ZERO + } else { + max_duration + .saturating_sub(slice_started.elapsed()) + .max(Duration::from_nanos(1)) + }; + DecodeBudget::new( + max_entries.saturating_sub(slice_entries).max(1), + max_bytes + .saturating_sub(guard.cursor.pos().saturating_sub(slice_start_pos)) + .max(1), + remaining_duration, + ) + }; + + let step = { + let body = body_lease + .as_ref() + .expect("body_lease stays Some until cursor is_done") + .as_slice(); + let dec = guard + .decoder + .as_mut() + .expect("decoder stays Some until cursor is_done"); + dec.step_decode(&mut guard.cursor, body, step_budget) + }; + + let records = match step { + Ok(DecodeStep::Pending) => { + tokio::task::yield_now().await; + slice_start_pos = guard.cursor.pos(); + slice_entries = 0; + slice_started = StdInstant::now(); + continue; + } + Ok(DecodeStep::Batch(records)) => records, + Ok(DecodeStep::Done) => { + guard.release(); + let _ = body_lease.take(); + break; + } + Err(err) => { + guard.release(); + let _ = body_lease.take(); + return ControlFlow::Continue(( + decoded_points, + batches_emitted, + Err(ScrapeError::Decode(err)), + )); + } + }; + + // Release both pooled permits before awaiting `on_batch` once the final chunk of the + // scrape has been materialized so downstream sink awaits do not block peer scrapes. + if guard.cursor.is_done() { + guard.release(); + let _ = body_lease.take(); + } + + let pts = records.num_data_points(); + decoded_points = decoded_points.saturating_add(pts as u64); + slice_entries = slice_entries.saturating_add(pts.max(1)); + + if !records.is_empty() { + batches_emitted += 1; + if sink + .on_batch(target, scrape_timestamp_ms, records) + .await + .is_break() + { + return ControlFlow::Break(()); + } + } + + if let Some(body) = body_lease.as_ref() + && guard.cursor.pos() < body.len() + && (slice_entries >= max_entries + || guard.cursor.pos().saturating_sub(slice_start_pos) >= max_bytes + || slice_started.elapsed() >= max_duration) + { + tokio::task::yield_now().await; + slice_start_pos = guard.cursor.pos(); + slice_entries = 0; + slice_started = StdInstant::now(); + } + } + + guard.release(); + drop(body_lease); + ControlFlow::Continue((decoded_points, batches_emitted, Ok(()))) + } + + async fn run_once_with<'a, Exec, Fut>( + &'a self, + targets: &'a [ScrapeTarget], + exec_one: Exec, + ) -> ControlFlow<()> + where + Exec: Fn(&'a ScrapeTarget) -> Fut, + Fut: Future>, + { + match self.config.scheduling { + SchedulingMode::Sequential { .. } => { + for target in targets { + if exec_one(target).await.is_break() { + return ControlFlow::Break(()); + } + tokio::task::yield_now().await; + } + ControlFlow::Continue(()) + } + SchedulingMode::Concurrent { max_in_flight, .. } => { + let cap = max_in_flight.get(); + let mut iter = targets.iter(); + let mut active = FuturesUnordered::new(); + + while active.len() < cap + && let Some(target) = iter.next() + { + active.push(exec_one(target)); + } + + while let Some(res) = active.next().await { + if res.is_break() { + return ControlFlow::Break(()); + } + if let Some(target) = iter.next() { + active.push(exec_one(target)); + } + } + ControlFlow::Continue(()) + } + } + } + + async fn run_loop( + &self, + initial_targets: Vec, + ctrl_rx: mpsc::UnboundedReceiver, + exec_one: Exec, + ) where + Exec: Fn(Arc) -> Fut, + Fut: Future>, + { + match self.config.scheduling { + SchedulingMode::Sequential { + schedule: SequentialSchedule::CycleTick, + } => { + self.run_sequential_cycle_tick(initial_targets, ctrl_rx, exec_one) + .await; + } + SchedulingMode::Sequential { + schedule: SequentialSchedule::Jittered, + } => { + self.run_jittered(initial_targets, ctrl_rx, 1, exec_one) + .await; + } + SchedulingMode::Concurrent { max_in_flight, .. } => { + self.run_jittered(initial_targets, ctrl_rx, max_in_flight.get(), exec_one) + .await; + } + } + } + + async fn run_sequential_cycle_tick( + &self, + initial_targets: Vec, + mut ctrl_rx: mpsc::UnboundedReceiver, + exec_one: Exec, + ) where + Exec: Fn(Arc) -> Fut, + Fut: Future>, + { + let mut targets: Vec> = + initial_targets.into_iter().map(Arc::new).collect(); + let interval = self.config.scrape_interval; + let start_now = Instant::now(); + let mut next_due = start_now + .checked_add(self.config.initial_delay) + .unwrap_or(start_now); + + loop { + tokio::select! { + biased; + + cmd = ctrl_rx.recv() => { + match cmd { + Some(ScrapeCommand::UpdateTargets(new_targets)) => { + targets = new_targets.into_iter().map(Arc::new).collect(); + } + Some(ScrapeCommand::Shutdown) | None => return, + } + } + + () = sleep_until(next_due) => { + let now = Instant::now(); + let (advanced_due, missed_ticks) = + advance_grid_deadline(next_due, now, interval); + if missed_ticks > 1 { + self.counters + .overrun + .fetch_add(missed_ticks - 1, Ordering::Relaxed); + } + next_due = advanced_due; + + let snapshot = targets.clone(); + for target in snapshot { + let fut = exec_one(target); + tokio::pin!(fut); + loop { + tokio::select! { + biased; + + cmd = ctrl_rx.recv() => { + match cmd { + Some(ScrapeCommand::UpdateTargets(new_targets)) => { + // Stage the updated target list for the next cycle + // tick without cancelling the in-flight scrape or + // skipping remaining targets in the current pass. + targets = new_targets.into_iter().map(Arc::new).collect(); + } + Some(ScrapeCommand::Shutdown) | None => return, + } + } + + out = &mut fut => { + if out.is_break() { + return; + } + break; + } + } + } + tokio::task::yield_now().await; + } + + // If the sequential pass ran past one or more scheduled grid ticks, skip those + // missed ticks and advance to the next future grid point (matching Go time.Ticker). + let finish_now = Instant::now(); + if finish_now >= next_due { + let (skipped_due, skipped_ticks) = + advance_grid_deadline(next_due, finish_now, interval); + self.counters + .overrun + .fetch_add(skipped_ticks, Ordering::Relaxed); + next_due = skipped_due; + } + } + } + } + } + + async fn run_jittered( + &self, + initial_targets: Vec, + mut ctrl_rx: mpsc::UnboundedReceiver, + max_in_flight: usize, + exec_one: Exec, + ) where + Exec: Fn(Arc) -> Fut, + Fut: Future>, + { + let start_now = Instant::now(); + let start_base = start_now + .checked_add(self.config.initial_delay) + .unwrap_or(start_now); + let mut schedule = + TargetSchedule::new(self.config.scrape_interval, self.config.jitter_seed); + schedule.sync_targets(initial_targets, start_base); + + let mut in_flight = FuturesUnordered::new(); + + loop { + if in_flight.len() < max_in_flight { + let now = Instant::now(); + while in_flight.len() < max_in_flight + && let Some(due) = schedule.pop_due(now) + { + let fut = exec_one(due.target); + in_flight.push(async move { + let out = fut.await; + (due.slot_id, due.generation, out) + }); + } + let overruns = schedule.take_overruns(); + if overruns > 0 { + self.counters.overrun.fetch_add(overruns, Ordering::Relaxed); + } + } + + let next_wake = if in_flight.len() < max_in_flight && !schedule.is_empty() { + schedule.peek_next_due() + } else { + None + }; + + tokio::select! { + biased; + + cmd = ctrl_rx.recv() => { + match cmd { + Some(ScrapeCommand::UpdateTargets(new_targets)) => { + schedule.sync_targets(new_targets, Instant::now().max(start_base)); + } + Some(ScrapeCommand::Shutdown) | None => return, + } + } + + Some((slot_id, generation, out)) = in_flight.next(), if !in_flight.is_empty() => { + schedule.complete(slot_id, generation, Instant::now()); + let overruns = schedule.take_overruns(); + if overruns > 0 { + self.counters.overrun.fetch_add(overruns, Ordering::Relaxed); + } + if out.is_break() { + return; + } + } + + () = async { + match next_wake { + Some(deadline) => sleep_until(deadline).await, + None => std::future::pending::<()>().await, + } + }, if next_wake.is_some() => {} + } + } + } +} diff --git a/crates/scrape-manager/src/scrape/pool.rs b/crates/scrape-manager/src/scrape/pool.rs new file mode 100644 index 0000000..602f38b --- /dev/null +++ b/crates/scrape-manager/src/scrape/pool.rs @@ -0,0 +1,416 @@ +// Copyright 2026 The Prometheus Authors +// 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 std::num::NonZeroUsize; +use std::ops::{Deref, DerefMut}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "arrow")] +use prometheus_expfmt::arrow::ArrowDecoder; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +use crate::scrape::http::PooledGzipDecoder; + +/// Bounded pool of reusable HTTP response body buffers. +/// +/// Concurrent scrapes acquire a [`PooledBodyBuffer`] only after HTTP response headers arrive, and +/// return the cleared `Vec` on drop. Any buffer that grew past `max_retained_body_bytes` on an +/// outlier scrape shrinks back to `max_retained_body_bytes` before returning to the idle freelist. +#[derive(Clone, Debug)] +pub struct BodyBufferPool { + inner: Arc, +} + +#[derive(Debug)] +struct IdleBodySlot { + buf: Vec, + gzip: Option>, +} + +#[derive(Debug)] +struct BodyPoolInner { + semaphore: Arc, + idle: Mutex>, + max_retained_bytes: usize, + active_leases: AtomicUsize, + peak_leases: AtomicUsize, + total_created: AtomicUsize, +} + +impl BodyBufferPool { + /// Creates a new [`BodyBufferPool`] allowing at most `max_buffers` concurrent leases and + /// retaining at most `max_retained_bytes` per idle buffer (`0` retains nothing). + #[must_use] + pub fn new(max_buffers: NonZeroUsize, max_retained_bytes: usize) -> Self { + let cap = max_buffers.get(); + Self { + inner: Arc::new(BodyPoolInner { + semaphore: Arc::new(Semaphore::new(cap)), + idle: Mutex::new(Vec::with_capacity(cap)), + max_retained_bytes, + active_leases: AtomicUsize::new(0), + peak_leases: AtomicUsize::new(0), + total_created: AtomicUsize::new(0), + }), + } + } + + /// Waits for a buffer permit and returns a cleared [`PooledBodyBuffer`], pre-reserving up to + /// `capacity_hint` bytes when starting from a fresh allocation. + pub async fn acquire(&self, capacity_hint: usize) -> PooledBodyBuffer { + let permit = Arc::clone(&self.inner.semaphore) + .acquire_owned() + .await + .expect("BodyBufferPool semaphore is never closed"); + + let active = self.inner.active_leases.fetch_add(1, Ordering::Relaxed) + 1; + self.inner.peak_leases.fetch_max(active, Ordering::Relaxed); + + let mut slot = { + let mut idle = self + .inner + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idle.pop() + } + .unwrap_or_else(|| { + self.inner.total_created.fetch_add(1, Ordering::Relaxed); + IdleBodySlot { + buf: Vec::new(), + gzip: None, + } + }); + + slot.buf.clear(); + let mut leased = PooledBodyBuffer { + buf: slot.buf, + gzip: slot.gzip, + pool: Arc::clone(&self.inner), + _permit: permit, + }; + if capacity_hint > leased.buf.capacity() { + leased.buf.reserve(capacity_hint - leased.buf.capacity()); + } + leased + } + + /// Number of buffers currently leased by active scrapes. + #[must_use] + pub fn active_leases(&self) -> usize { + self.inner.active_leases.load(Ordering::Relaxed) + } + + /// High-water mark of simultaneously leased body buffers since pool creation. + #[must_use] + pub fn peak_leases(&self) -> usize { + self.inner.peak_leases.load(Ordering::Relaxed) + } + + /// Total distinct `Vec` instances ever allocated by this pool (`<= max_buffers`). + #[must_use] + pub fn total_buffers_created(&self) -> usize { + self.inner.total_created.load(Ordering::Relaxed) + } + + /// Sum of `Vec::capacity()` across buffers currently parked in the idle freelist. + #[must_use] + pub fn retained_memory_bytes(&self) -> usize { + let idle = self + .inner + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idle.iter().map(|slot| slot.buf.capacity()).sum() + } +} + +/// RAII lease over a reusable `Vec` from a [`BodyBufferPool`]. +#[derive(Debug)] +pub struct PooledBodyBuffer { + buf: Vec, + gzip: Option>, + pool: Arc, + // Field drop order guarantees `buf` is pushed back to `pool.idle` in `Drop` before `_permit` + // releases its semaphore slot to a waiting task. + _permit: OwnedSemaphorePermit, +} + +impl PooledBodyBuffer { + /// Returns the buffered response payload bytes. + #[must_use] + pub fn as_slice(&self) -> &[u8] { + self.buf.as_slice() + } + + pub(crate) fn parts_mut(&mut self) -> (&mut Vec, &mut Option>) { + (&mut self.buf, &mut self.gzip) + } +} + +impl Deref for PooledBodyBuffer { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.buf + } +} + +impl DerefMut for PooledBodyBuffer { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.buf + } +} + +impl AsRef<[u8]> for PooledBodyBuffer { + fn as_ref(&self) -> &[u8] { + self.buf.as_slice() + } +} + +impl Drop for PooledBodyBuffer { + fn drop(&mut self) { + let mut buf = std::mem::take(&mut self.buf); + buf.clear(); + if buf.capacity() > self.pool.max_retained_bytes { + buf.shrink_to(self.pool.max_retained_bytes); + } + let mut gzip = self.gzip.take(); + if self.pool.max_retained_bytes == 0 { + gzip = None; + } else if let Some(dec) = gzip.as_mut() { + dec.shrink_retained(self.pool.max_retained_bytes); + } + self.pool.active_leases.fetch_sub(1, Ordering::Relaxed); + let mut idle = self + .pool + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idle.push(IdleBodySlot { buf, gzip }); + } +} + +/// Bounded pool of reusable [`ArrowDecoder`] instances. +#[cfg(feature = "arrow")] +#[derive(Clone, Debug)] +pub struct DecoderPool { + inner: Arc, +} + +#[cfg(feature = "arrow")] +#[derive(Debug)] +struct DecoderPoolInner { + semaphore: Arc, + idle: Mutex>, + max_retained_bytes: usize, + active_leases: AtomicUsize, + peak_leases: AtomicUsize, + total_created: AtomicUsize, +} + +#[cfg(feature = "arrow")] +impl DecoderPool { + /// Creates a new [`DecoderPool`] allowing at most `max_decoders` concurrent leases and + /// retaining at most `max_retained_bytes` of scratch capacity per idle decoder (`0` retains + /// no idle decoders). + #[must_use] + pub fn new(max_decoders: NonZeroUsize, max_retained_bytes: usize) -> Self { + let cap = max_decoders.get(); + Self { + inner: Arc::new(DecoderPoolInner { + semaphore: Arc::new(Semaphore::new(cap)), + idle: Mutex::new(Vec::with_capacity(cap)), + max_retained_bytes, + active_leases: AtomicUsize::new(0), + peak_leases: AtomicUsize::new(0), + total_created: AtomicUsize::new(0), + }), + } + } + + /// Waits for a decoder permit and returns a reusable [`PooledDecoder`]. + pub async fn acquire(&self) -> PooledDecoder { + let permit = Arc::clone(&self.inner.semaphore) + .acquire_owned() + .await + .expect("DecoderPool semaphore is never closed"); + + let active = self.inner.active_leases.fetch_add(1, Ordering::Relaxed) + 1; + self.inner.peak_leases.fetch_max(active, Ordering::Relaxed); + + let decoder = { + let mut idle = self + .inner + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idle.pop() + } + .unwrap_or_else(|| { + self.inner.total_created.fetch_add(1, Ordering::Relaxed); + ArrowDecoder::new() + }); + + PooledDecoder { + decoder: Some(decoder), + pool: Arc::clone(&self.inner), + _permit: permit, + } + } + + /// High-water mark of simultaneously leased decoders since pool creation. + #[must_use] + pub fn peak_leases(&self) -> usize { + self.inner.peak_leases.load(Ordering::Relaxed) + } + + /// Total distinct [`ArrowDecoder`] instances ever allocated by this pool (`<= max_decoders`). + #[must_use] + pub fn total_decoders_created(&self) -> usize { + self.inner.total_created.load(Ordering::Relaxed) + } + + /// Sum of [`ArrowDecoder::retained_memory_bytes`] across decoders currently parked in the pool. + #[must_use] + pub fn retained_memory_bytes(&self) -> usize { + let idle = self + .inner + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idle.iter().map(ArrowDecoder::retained_memory_bytes).sum() + } +} + +/// RAII lease over a reusable [`ArrowDecoder`] from a [`DecoderPool`]. +#[cfg(feature = "arrow")] +#[derive(Debug)] +pub struct PooledDecoder { + decoder: Option, + pool: Arc, + _permit: OwnedSemaphorePermit, +} + +#[cfg(feature = "arrow")] +impl Deref for PooledDecoder { + type Target = ArrowDecoder; + + fn deref(&self) -> &Self::Target { + self.decoder + .as_ref() + .expect("PooledDecoder is Some until Drop") + } +} + +#[cfg(feature = "arrow")] +impl DerefMut for PooledDecoder { + fn deref_mut(&mut self) -> &mut Self::Target { + self.decoder + .as_mut() + .expect("PooledDecoder is Some until Drop") + } +} + +#[cfg(feature = "arrow")] +impl Drop for PooledDecoder { + fn drop(&mut self) { + self.pool.active_leases.fetch_sub(1, Ordering::Relaxed); + if let Some(mut decoder) = self.decoder.take() { + if decoder.retained_memory_bytes() > self.pool.max_retained_bytes { + let fresh = ArrowDecoder::new(); + if fresh.retained_memory_bytes() <= self.pool.max_retained_bytes { + decoder = fresh; + } else { + return; + } + } + let mut idle = self + .pool + .idle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + idle.push(decoder); + } + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use super::*; + + #[tokio::test] + async fn body_buffer_pool_reuses_slots_and_shrinks_outliers() { + let pool = BodyBufferPool::new(NonZeroUsize::MIN, 1_024); + let mut lease = pool.acquire(256).await; + assert_eq!(pool.active_leases(), 1); + lease.resize(16 * 1024, b'x'); + assert_eq!(lease.as_ref().len(), 16 * 1024); + drop(lease); + + assert_eq!(pool.active_leases(), 0); + assert_eq!(pool.peak_leases(), 1); + assert_eq!(pool.total_buffers_created(), 1); + assert!(pool.retained_memory_bytes() <= 1_024); + + let zero_pool = BodyBufferPool::new(NonZeroUsize::MIN, 0); + let mut z = zero_pool.acquire(512).await; + z.extend_from_slice(b"abc"); + drop(z); + assert_eq!(zero_pool.retained_memory_bytes(), 0); + } + + #[cfg(feature = "arrow")] + #[tokio::test] + async fn decoder_pool_caps_retained_memory_after_outlier_scrape() { + use prometheus_expfmt::arrow::DecodeOptions; + use prometheus_expfmt::format::Format; + + let baseline = ArrowDecoder::new().retained_memory_bytes(); + let cap = baseline + 16 * 1024; + let pool = DecoderPool::new(NonZeroUsize::MIN, cap); + + let mut big = String::from("# TYPE big_metric gauge\n"); + for i in 0..8_000 { + big.push_str(&format!( + "big_metric{{instance=\"host-{i}\",pod=\"pod-{i}-long-label-value\"}} {i}\n" + )); + } + + let mut dec = pool.acquire().await; + let _ = dec + .decode(big.as_bytes(), &Format::TEXT, &DecodeOptions::new()) + .expect("decode big"); + assert!( + dec.retained_memory_bytes() > cap, + "expected outlier scrape to grow decoder past {cap}, got {}", + dec.retained_memory_bytes() + ); + drop(dec); + + assert_eq!(pool.total_decoders_created(), 1); + assert!( + pool.retained_memory_bytes() <= cap, + "expected idle decoder <= {cap} bytes, got {}", + pool.retained_memory_bytes() + ); + + let zero_pool = DecoderPool::new(NonZeroUsize::MIN, 0); + let dec0 = zero_pool.acquire().await; + drop(dec0); + assert_eq!(zero_pool.retained_memory_bytes(), 0); + } +} diff --git a/crates/scrape-manager/src/scrape/schedule.rs b/crates/scrape-manager/src/scrape/schedule.rs new file mode 100644 index 0000000..e67b033 --- /dev/null +++ b/crates/scrape-manager/src/scrape/schedule.rs @@ -0,0 +1,537 @@ +// Copyright 2026 The Prometheus Authors +// 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 std::cmp::{Ordering, Reverse}; +use std::collections::{BinaryHeap, HashMap}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::Instant; + +use crate::scrape::ScrapeTarget; + +const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3; + +/// Computes the deterministic initial phase offset in `[0, interval)` for `target`. +/// +/// Mirrors Go Prometheus `Target.offset` by hashing `jitter_seed`, the target URL, and sorted +/// target labels with 64-bit FNV-1a without heap allocations. Identical targets always map to the +/// same offset within `interval`, spreading targets evenly across the scrape window. +#[must_use] +pub fn target_offset(target: &ScrapeTarget, interval: Duration, jitter_seed: u64) -> Duration { + let interval_ns = interval.as_nanos(); + if interval_ns == 0 { + return Duration::ZERO; + } + let hash = hash_target_identity(target, jitter_seed); + let rem_ns = u64::try_from(u128::from(hash) % interval_ns).unwrap_or(0); + Duration::from_nanos(rem_ns) +} + +fn hash_target_identity(target: &ScrapeTarget, jitter_seed: u64) -> u64 { + let mut h = FNV_OFFSET_BASIS_64; + h = fnv1a_bytes(h, &jitter_seed.to_le_bytes()); + h = fnv1a_bytes(h, target.url().as_str().as_bytes()); + h = fnv1a_byte(h, 0xff); + for (k, v) in target.labels() { + h = fnv1a_bytes(h, k.as_bytes()); + h = fnv1a_byte(h, 0x00); + h = fnv1a_bytes(h, v.as_bytes()); + h = fnv1a_byte(h, 0xff); + } + h +} + +#[inline] +const fn fnv1a_byte(mut hash: u64, byte: u8) -> u64 { + hash ^= byte as u64; + hash.wrapping_mul(FNV_PRIME_64) +} + +#[inline] +fn fnv1a_bytes(mut hash: u64, bytes: &[u8]) -> u64 { + for &b in bytes { + hash = fnv1a_byte(hash, b); + } + hash +} + +#[derive(Debug)] +struct TargetSlot { + target: Arc, + generation: u64, + next_due: Instant, + seen_epoch: u64, + in_flight: bool, + removed: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct HeapEntry { + due: Instant, + slot_id: usize, + generation: u64, +} + +impl Ord for HeapEntry { + fn cmp(&self, other: &Self) -> Ordering { + self.due + .cmp(&other.due) + .then_with(|| self.slot_id.cmp(&other.slot_id)) + .then_with(|| self.generation.cmp(&other.generation)) + } +} + +impl PartialOrd for HeapEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Target popped from [`TargetSchedule`] when its periodic scrape instant arrives. +#[derive(Clone, Debug)] +pub(crate) struct DueTarget { + pub(crate) slot_id: usize, + pub(crate) generation: u64, + pub(crate) target: Arc, +} + +/// Single-structure min-heap target timer queue (`O(N)` memory, `O(log N)` per scrape tick). +/// +/// Replaces spawning one sleeping task per target with a compact slot table and priority queue, +/// preserving exact per-target periodic grids (`t_0 + offset + k * interval`) and skipping +/// overlapping scrapes of an already in-flight target. +#[derive(Debug)] +pub(crate) struct TargetSchedule { + interval: Duration, + jitter_seed: u64, + next_generation: u64, + sync_epoch: u64, + slots: Vec>, + free_slots: Vec, + index_by_target: HashMap, usize>, + removed_in_flight: HashMap, usize>, + heap: BinaryHeap>, + overruns: u64, +} + +impl TargetSchedule { + pub(crate) fn new(interval: Duration, jitter_seed: u64) -> Self { + Self { + interval, + jitter_seed, + next_generation: 1, + sync_epoch: 0, + slots: Vec::new(), + free_slots: Vec::new(), + index_by_target: HashMap::new(), + removed_in_flight: HashMap::new(), + heap: BinaryHeap::new(), + overruns: 0, + } + } + + /// Reconciles the active target list against `targets` at `base_instant`. + /// + /// Unchanged targets preserve their existing `next_due` phase and `in_flight` state without + /// cloning URL or label allocations; newly discovered targets are scheduled at + /// `base_instant + target_offset(target)`. Duplicate targets within `targets` are deduplicated + /// automatically. + pub(crate) fn sync_targets(&mut self, targets: Vec, base_instant: Instant) { + self.sync_epoch = self.sync_epoch.wrapping_add(1); + let epoch = self.sync_epoch; + + for target in targets { + if let Some(&slot_id) = self.index_by_target.get(&target) { + if let Some(slot) = self.slots.get_mut(slot_id).and_then(Option::as_mut) { + slot.seen_epoch = epoch; + } + continue; + } + + // If `target` was removed while an earlier scrape was still in flight and re-added + // before that scrape completed, revive the existing in-flight slot so two scrapes of + // the same target never overlap. + if let Some(slot_id) = self.removed_in_flight.remove(&target) + && let Some(slot) = self.slots.get_mut(slot_id).and_then(Option::as_mut) + { + slot.removed = false; + slot.seen_epoch = epoch; + let due = slot.next_due; + let generation = slot.generation; + let target_arc = Arc::clone(&slot.target); + self.index_by_target.insert(target_arc, slot_id); + self.heap.push(Reverse(HeapEntry { + due, + slot_id, + generation, + })); + continue; + } + + let generation = self.next_generation; + self.next_generation = self.next_generation.wrapping_add(1); + let offset = target_offset(&target, self.interval, self.jitter_seed); + let due = base_instant.checked_add(offset).unwrap_or(base_instant); + let target_arc = Arc::new(target); + + let slot = TargetSlot { + target: Arc::clone(&target_arc), + generation, + next_due: due, + seen_epoch: epoch, + in_flight: false, + removed: false, + }; + + let slot_id = if let Some(reused) = self.free_slots.pop() { + self.slots[reused] = Some(slot); + reused + } else { + let id = self.slots.len(); + self.slots.push(Some(slot)); + id + }; + + self.index_by_target.insert(target_arc, slot_id); + self.heap.push(Reverse(HeapEntry { + due, + slot_id, + generation, + })); + } + + let slots = &mut self.slots; + let free_slots = &mut self.free_slots; + let removed_in_flight = &mut self.removed_in_flight; + self.index_by_target.retain(|target_key, &mut slot_id| { + let Some(slot) = slots.get_mut(slot_id).and_then(Option::as_mut) else { + return false; + }; + if slot.seen_epoch == epoch { + return true; + } + if slot.in_flight { + slot.removed = true; + removed_in_flight.insert(Arc::clone(target_key), slot_id); + } else { + slots[slot_id] = None; + free_slots.push(slot_id); + } + false + }); + + let active = self.index_by_target.len(); + if self.heap.len() > active.saturating_mul(2).max(64) { + self.compact_heap(active); + } + } + + fn compact_heap(&mut self, active: usize) { + let mut rebuilt = BinaryHeap::with_capacity(active); + for (slot_id, slot_opt) in self.slots.iter().enumerate() { + if let Some(slot) = slot_opt + && !slot.removed + { + rebuilt.push(Reverse(HeapEntry { + due: slot.next_due, + slot_id, + generation: slot.generation, + })); + } + } + self.heap = rebuilt; + } + + pub(crate) fn is_empty(&self) -> bool { + self.index_by_target.is_empty() + } + + /// Returns the earliest `Instant` at which a valid non-removed target is due. + pub(crate) fn peek_next_due(&mut self) -> Option { + while let Some(&Reverse(entry)) = self.heap.peek() { + let valid = self + .slots + .get(entry.slot_id) + .and_then(Option::as_ref) + .is_some_and(|s| { + !s.removed && s.generation == entry.generation && s.next_due == entry.due + }); + if valid { + return Some(entry.due); + } + self.heap.pop(); + } + None + } + + /// Pops one target whose scheduled `due <= now`, advancing its periodic timer to the next grid + /// tick (`due + k * interval`) immediately so scrape latency never shifts future intervals. + pub(crate) fn pop_due(&mut self, now: Instant) -> Option { + while let Some(&Reverse(entry)) = self.heap.peek() { + if entry.due > now { + return None; + } + self.heap.pop(); + + let Some(slot) = self.slots.get_mut(entry.slot_id).and_then(Option::as_mut) else { + continue; + }; + if slot.removed || slot.generation != entry.generation || slot.next_due != entry.due { + continue; + } + + let (next_due, missed_ticks) = advance_grid_deadline(entry.due, now, self.interval); + let skipped = missed_ticks.saturating_sub(1); + slot.next_due = next_due; + self.heap.push(Reverse(HeapEntry { + due: next_due, + slot_id: entry.slot_id, + generation: slot.generation, + })); + + if slot.in_flight { + self.overruns = self.overruns.saturating_add(missed_ticks); + continue; + } + + self.overruns = self.overruns.saturating_add(skipped); + slot.in_flight = true; + return Some(DueTarget { + slot_id: entry.slot_id, + generation: slot.generation, + target: Arc::clone(&slot.target), + }); + } + None + } + + /// Marks an in-flight scrape finished at `now`, advancing past any periodic ticks that elapsed + /// while the scrape was still running (for example when `in_flight.len() == max_in_flight` + /// suppressed `pop_due` during the overrun). + pub(crate) fn complete(&mut self, slot_id: usize, generation: u64, now: Instant) { + let mut new_heap_entry = None; + let mut added_overruns = 0_u64; + let reclaim = if let Some(slot) = self.slots.get_mut(slot_id).and_then(Option::as_mut) + && slot.generation == generation + { + slot.in_flight = false; + if slot.removed { + Some(Arc::clone(&slot.target)) + } else { + if slot.next_due <= now { + let (next_due, missed_ticks) = + advance_grid_deadline(slot.next_due, now, self.interval); + slot.next_due = next_due; + added_overruns = missed_ticks; + new_heap_entry = Some(HeapEntry { + due: next_due, + slot_id, + generation: slot.generation, + }); + } + None + } + } else { + None + }; + if added_overruns > 0 { + self.overruns = self.overruns.saturating_add(added_overruns); + } + if let Some(entry) = new_heap_entry { + self.heap.push(Reverse(entry)); + } + if let Some(target) = reclaim { + self.removed_in_flight.remove(&target); + self.slots[slot_id] = None; + self.free_slots.push(slot_id); + } + } + + /// Drains and returns newly accumulated interval overrun counts since the last call. + pub(crate) fn take_overruns(&mut self) -> u64 { + std::mem::take(&mut self.overruns) + } +} + +/// Advances `due` (`<= now`) forward by `k * interval` onto the earliest grid instant strictly +/// after `now`, returning `(next_due, k)` without panicking on `Duration` or `Instant` overflow. +pub(crate) fn advance_grid_deadline( + due: Instant, + now: Instant, + interval: Duration, +) -> (Instant, u64) { + let interval_ns = interval.as_nanos().max(1); + let elapsed = now.saturating_duration_since(due); + let missed_ticks = elapsed.as_nanos() / interval_ns + 1; + let next_due = u32::try_from(missed_ticks) + .ok() + .and_then(|ticks| interval.checked_mul(ticks)) + .and_then(|advance| due.checked_add(advance)) + .filter(|&candidate| candidate > now) + .or_else(|| now.checked_add(interval)) + .unwrap_or(now); + let missed_u64 = u64::try_from(missed_ticks).unwrap_or(u64::MAX); + (next_due, missed_u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_offset_is_deterministic_and_bounded_by_interval() { + let interval = Duration::from_secs(15); + let t1 = ScrapeTarget::parse("http://10.0.0.1:9100/metrics") + .expect("url") + .with_label("pod", "a"); + let t2 = ScrapeTarget::parse("http://10.0.0.2:9100/metrics") + .expect("url") + .with_label("pod", "b"); + + let off1_a = target_offset(&t1, interval, 42); + let off1_b = target_offset(&t1, interval, 42); + let off2 = target_offset(&t2, interval, 42); + + assert_eq!(off1_a, off1_b); + assert!(off1_a < interval); + assert!(off2 < interval); + assert_ne!(off1_a, off2); + assert_eq!(target_offset(&t1, Duration::ZERO, 42), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] + async fn schedule_preserves_phase_on_sync_and_counts_in_flight_overruns() { + let interval = Duration::from_secs(10); + let base = Instant::now(); + let t1 = ScrapeTarget::parse("http://127.0.0.1:8081/metrics").expect("t1"); + let t2 = ScrapeTarget::parse("http://127.0.0.1:8082/metrics").expect("t2"); + + let mut sched = TargetSchedule::new(interval, 7); + sched.sync_targets(vec![t1.clone()], base); + + let first_due = sched.peek_next_due().expect("has due"); + // Re-syncing t1 plus adding t2 right after first_due must keep t1's original first_due intact. + let later = first_due + Duration::from_nanos(1); + sched.sync_targets(vec![t1.clone(), t2.clone()], later); + + let due_at_first = first_due; + let popped = sched.pop_due(due_at_first).expect("t1 due"); + assert_eq!(popped.target.url(), t1.url()); + assert_eq!(sched.take_overruns(), 0); + + // Leave t1 `in_flight` across its next due tick (`first_due + 10s`) -> skips and records 1 overrun. + let second_tick = first_due + interval; + while let Some(other) = sched.pop_due(second_tick) { + // Only t2 can pop while t1 is still in_flight. + assert_eq!(other.target.url(), t2.url()); + sched.complete(other.slot_id, other.generation, second_tick); + } + assert_eq!(sched.take_overruns(), 1); + + // Completing t1 allows it to fire cleanly at `first_due + 20s`. + sched.complete( + popped.slot_id, + popped.generation, + second_tick + Duration::from_secs(1), + ); + let third_tick = first_due + interval * 2; + let mut fired_urls = Vec::new(); + while let Some(d) = sched.pop_due(third_tick) { + fired_urls.push(d.target.url().as_str().to_owned()); + sched.complete(d.slot_id, d.generation, third_tick); + } + assert!(fired_urls.iter().any(|u| u == t1.url().as_str())); + } + + #[tokio::test(start_paused = true)] + async fn readding_in_flight_target_does_not_overlap() { + let interval = Duration::from_secs(10); + let base = Instant::now(); + let t1 = ScrapeTarget::parse("http://127.0.0.1:8090/metrics").expect("t1"); + + let mut sched = TargetSchedule::new(interval, 11); + sched.sync_targets(vec![t1.clone()], base); + + let first_due = sched.peek_next_due().expect("due"); + let active = sched.pop_due(first_due).expect("pop first"); + + // Flap SD: remove `t1` while in flight, peek (which drops its heap entry), then re-add `t1`. + sched.sync_targets(Vec::new(), first_due + Duration::from_secs(1)); + assert!(sched.is_empty()); + assert!(sched.peek_next_due().is_none()); + + sched.sync_targets(vec![t1.clone()], first_due + Duration::from_secs(2)); + assert!(!sched.is_empty()); + + // At the next grid tick while the first scrape is still running, `pop_due` must skip `t1` + // and count 1 overrun rather than starting an overlapping second scrape. + assert!(sched.pop_due(first_due + interval).is_none()); + assert_eq!(sched.take_overruns(), 1); + + // Once the first scrape finishes, `t1` runs on the following tick. + sched.complete( + active.slot_id, + active.generation, + first_due + interval + Duration::from_secs(1), + ); + let next = sched + .pop_due(first_due + interval * 2) + .expect("fires after completion"); + assert_eq!(next.target.url(), t1.url()); + } + + #[tokio::test(start_paused = true)] + async fn complete_after_saturated_overrun_skips_missed_tick_and_counts_overrun() { + let interval = Duration::from_secs(10); + let base = Instant::now(); + let t1 = ScrapeTarget::parse("http://127.0.0.1:8091/metrics").expect("t1"); + + let mut sched = TargetSchedule::new(interval, 19); + sched.sync_targets(vec![t1.clone()], base); + + let first_due = sched.peek_next_due().expect("due"); + let active = sched.pop_due(first_due).expect("pop first"); + + // Simulate `in_flight.len() == max_in_flight`: `pop_due` is never called at `first_due + 10s`, + // and the scrape finishes 4s into the missed interval (`first_due + 14s`). + let finish_at = first_due + Duration::from_secs(14); + sched.complete(active.slot_id, active.generation, finish_at); + assert_eq!(sched.take_overruns(), 1); + + // Must NOT immediately re-scrape at `first_due + 14s`; must wait until `first_due + 20s`. + assert!(sched.pop_due(finish_at).is_none()); + let resumed = sched + .pop_due(first_due + interval * 2) + .expect("fires on next grid tick"); + assert_eq!(resumed.target.url(), t1.url()); + } + + #[tokio::test(start_paused = true)] + async fn rapid_target_churn_compacts_stale_heap_entries() { + let interval = Duration::from_secs(60); + let base = Instant::now(); + let mut sched = TargetSchedule::new(interval, 3); + + for batch in 0..200 { + let t = ScrapeTarget::parse(&format!("http://127.0.0.1:9100/m/{batch}")).expect("url"); + sched.sync_targets(vec![t], base); + } + assert!( + sched.heap.len() <= 64, + "expected compacted heap <= 64 entries after churn, got {}", + sched.heap.len() + ); + } +} diff --git a/crates/scrape-manager/tests/scrape_manager.rs b/crates/scrape-manager/tests/scrape_manager.rs new file mode 100644 index 0000000..a4aa656 --- /dev/null +++ b/crates/scrape-manager/tests/scrape_manager.rs @@ -0,0 +1,752 @@ +// Copyright 2026 The Prometheus Authors +// 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. + +//! Integration tests for `prometheus_scrape_manager::scrape::ScrapeManager`. + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::ops::ControlFlow; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use prometheus_expfmt::format::{FMT_TEXT, Format}; +use prometheus_scrape_manager::scrape::{ + BodyBufferPool, FetchedScrape, RawScrapeSink, SchedulingMode, ScrapeConfig, ScrapeError, + ScrapeManager, ScrapeTarget, TargetFetcher, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::time::Instant; + +struct SimulatedLatencyFetcher { + slow_url: String, + slow_delay: Duration, + fast_delay: Duration, + payload: Vec, +} + +impl TargetFetcher for SimulatedLatencyFetcher { + async fn fetch( + &self, + target: &ScrapeTarget, + pool: &BodyBufferPool, + ) -> Result { + let delay = if target.url().as_str() == self.slow_url { + self.slow_delay + } else { + self.fast_delay + }; + // Simulate server computation / network RTT before HTTP 200 headers arrive (zero buffer held). + tokio::time::sleep(delay).await; + + let mut body = pool.acquire(self.payload.len()).await; + body.extend_from_slice(&self.payload); + // Simulate brief body transfer while holding the pooled buffer. + tokio::time::sleep(Duration::from_millis(5)).await; + + Ok(FetchedScrape { + body, + format: Format::from(FMT_TEXT), + scrape_timestamp_ms: 1_700_000_000_000, + fetch_duration: delay + Duration::from_millis(5), + wire_bytes: self.payload.len(), + }) + } +} + +struct RecordingRawSink { + seen: Mutex>>, +} + +impl RecordingRawSink { + fn new() -> Self { + Self { + seen: Mutex::new(HashMap::new()), + } + } +} + +impl RawScrapeSink for RecordingRawSink { + async fn on_raw_scrape( + &self, + target: &ScrapeTarget, + result: Result, + ) -> ControlFlow<()> { + assert!(result.is_ok(), "unexpected scrape error: {result:?}"); + self.seen + .lock() + .expect("lock") + .entry(target.url().as_str().to_owned()) + .or_default() + .push(Instant::now()); + ControlFlow::Continue(()) + } +} + +#[tokio::test(start_paused = true)] +async fn concurrent_mode_preserves_equal_intervals_despite_stalled_target_and_bounds_buffers() { + let interval = Duration::from_secs(10); + let targets: Vec = (0..8) + .map(|i| { + ScrapeTarget::parse(&format!("http://10.0.0.{}:9100/metrics", i + 1)) + .expect("url") + .with_label("instance", format!("target-{i}")) + }) + .collect(); + let slow_url = targets[0].url().as_str().to_owned(); + + let fetcher = SimulatedLatencyFetcher { + slow_url: slow_url.clone(), + slow_delay: Duration::from_secs(8), + fast_delay: Duration::from_millis(40), + payload: b"# TYPE up_metric gauge\nup_metric 1\n".repeat(64), + }; + + let cfg = ScrapeConfig::new("node") + .with_scrape_interval(interval) + .with_scrape_timeout(Duration::from_secs(9)) + .with_scheduling(SchedulingMode::concurrent_with_pools( + NonZeroUsize::new(8).unwrap(), + NonZeroUsize::new(2).unwrap(), + NonZeroUsize::new(1).unwrap(), + )); + + let manager = ScrapeManager::new(cfg, fetcher).expect("manager"); + let (ctrl, ctrl_rx) = ScrapeManager::control_channel(); + let sink = RecordingRawSink::new(); + + let shutdown_ctrl = ctrl.clone(); + let driver = async move { + tokio::time::sleep(Duration::from_secs(45)).await; + let _ = shutdown_ctrl.shutdown(); + }; + + tokio::join!(manager.run_raw(targets.clone(), ctrl_rx, &sink), driver); + + let stats = manager.stats(); + assert_eq!(stats.scrapes_failed, 0); + assert_eq!(stats.scrapes_overrun, 0); + // Even with 8 targets and an 8-second stall on target-0, at most 2 body buffers are ever created. + assert!( + stats.total_body_buffers_created <= 2, + "expected <= 2 pooled buffers, got {}", + stats.total_body_buffers_created + ); + assert!(stats.peak_buffered_bodies <= 2); + + let recorded = sink.seen.lock().expect("lock"); + for t in &targets[1..] { + let stamps = recorded.get(t.url().as_str()).expect("fast target scraped"); + assert!( + stamps.len() >= 4, + "expected >= 4 scrapes in 45s at 10s interval, got {}", + stamps.len() + ); + for pair in stamps.windows(2) { + let delta = pair[1].duration_since(pair[0]); + let diff_ms = delta.as_millis().abs_diff(interval.as_millis()); + assert!( + diff_ms <= 25, + "expected exact 10s interval on fast target {}, got {delta:?}", + t.url() + ); + } + } +} + +#[tokio::test(start_paused = true)] +async fn sequential_modes_use_single_buffer_and_count_overruns() { + for mode in [ + SchedulingMode::sequential(), + SchedulingMode::sequential_jittered(), + ] { + let targets: Vec = (0..3) + .map(|i| ScrapeTarget::parse(&format!("http://10.1.0.{}:9100/metrics", i + 1)).unwrap()) + .collect(); + + let fetcher = SimulatedLatencyFetcher { + slow_url: targets[0].url().as_str().to_owned(), + slow_delay: Duration::from_secs(7), + fast_delay: Duration::from_secs(7), + payload: b"metric_a 1\n".to_vec(), + }; + + // 3 targets * 7s = 21s per full pass, overrunning a 10s scrape_interval in both sequential modes. + let cfg = ScrapeConfig::new("seq") + .with_scrape_interval(Duration::from_secs(10)) + .with_scrape_timeout(Duration::from_secs(10)) + .with_scheduling(mode); + + let manager = ScrapeManager::new(cfg, fetcher).expect("manager"); + let (ctrl, ctrl_rx) = ScrapeManager::control_channel(); + let sink = RecordingRawSink::new(); + + let driver = async move { + tokio::time::sleep(Duration::from_secs(35)).await; + let _ = ctrl.shutdown(); + }; + + tokio::join!(manager.run_raw(targets, ctrl_rx, &sink), driver); + + let stats = manager.stats(); + assert_eq!(stats.total_body_buffers_created, 1); + assert_eq!(stats.peak_buffered_bodies, 1); + assert!( + stats.scrapes_overrun >= 1, + "expected overrun count >= 1 in mode {mode:?}, got {stats:?}" + ); + } +} + +#[tokio::test(start_paused = true)] +async fn cycle_tick_finishes_current_pass_across_target_updates() { + let t0 = ScrapeTarget::parse("http://10.2.0.1:9100/metrics").unwrap(); + let t1 = ScrapeTarget::parse("http://10.2.0.2:9100/metrics").unwrap(); + let t2 = ScrapeTarget::parse("http://10.2.0.3:9100/metrics").unwrap(); + let t3 = ScrapeTarget::parse("http://10.2.0.4:9100/metrics").unwrap(); + + let fetcher = SimulatedLatencyFetcher { + slow_url: t0.url().as_str().to_owned(), + slow_delay: Duration::from_secs(2), + fast_delay: Duration::from_secs(2), + payload: b"metric_a 1\n".to_vec(), + }; + + let cfg = ScrapeConfig::new("seq-update") + .with_scrape_interval(Duration::from_secs(10)) + .with_scrape_timeout(Duration::from_secs(5)) + .with_scheduling(SchedulingMode::sequential()); + + let manager = ScrapeManager::new(cfg, fetcher).expect("manager"); + let (ctrl, ctrl_rx) = ScrapeManager::control_channel(); + let sink = RecordingRawSink::new(); + + let t3_for_driver = t3.clone(); + let driver = async move { + // Send `UpdateTargets` at t = 1s while `t0` is mid-scrape; `t0`, `t1`, and `t2` must all + // complete pass 1, and `t3` must take effect on pass 2 (t = 10s). + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(ctrl.update_targets(vec![t3_for_driver])); + tokio::time::sleep(Duration::from_secs(14)).await; + let _ = ctrl.shutdown(); + }; + + tokio::join!( + manager.run_raw(vec![t0.clone(), t1.clone(), t2.clone()], ctrl_rx, &sink), + driver + ); + + let stats = manager.stats(); + assert_eq!( + stats.scrapes_started, + stats.scrapes_succeeded + stats.scrapes_failed + ); + assert_eq!(stats.scrapes_succeeded, 4); + + let seen = sink.seen.lock().expect("lock"); + assert_eq!(seen.get(t0.url().as_str()).map(Vec::len), Some(1)); + assert_eq!(seen.get(t1.url().as_str()).map(Vec::len), Some(1)); + assert_eq!(seen.get(t2.url().as_str()).map(Vec::len), Some(1)); + assert_eq!(seen.get(t3.url().as_str()).map(Vec::len), Some(1)); +} + +#[tokio::test(start_paused = true)] +async fn empty_startup_honors_initial_delay_on_update_targets_and_stops_on_channel_drop() { + let t1 = ScrapeTarget::parse("http://10.3.0.1:9100/metrics").unwrap(); + let fetcher = SimulatedLatencyFetcher { + slow_url: String::new(), + slow_delay: Duration::from_millis(10), + fast_delay: Duration::from_millis(10), + payload: b"metric_a 1\n".to_vec(), + }; + + let cfg = ScrapeConfig::new("dynamic-sd") + .with_scrape_interval(Duration::from_secs(10)) + .with_scrape_timeout(Duration::from_secs(5)) + .with_initial_delay(Duration::from_secs(6)) + .with_scheduling(SchedulingMode::concurrent(NonZeroUsize::new(4).unwrap())); + + let start = Instant::now(); + let manager = ScrapeManager::new(cfg, fetcher).expect("manager"); + let (ctrl, ctrl_rx) = ScrapeManager::control_channel(); + let sink = RecordingRawSink::new(); + let observer = ctrl.clone(); + + let t1_for_driver = t1.clone(); + let driver = async move { + // Start with 0 targets; push `t1` at t = 1s while still inside the 6s `initial_delay`. + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(ctrl.update_targets(vec![t1_for_driver])); + + // Wait past `initial_delay + scrape_interval` so `t1` scrapes at least once after t >= 6s. + tokio::time::sleep(Duration::from_secs(16)).await; + assert!(ctrl.update_targets(Vec::new())); + tokio::time::sleep(Duration::from_secs(1)).await; + // Drop the last active sender (`ctrl`) after dropping `observer` below -> clean exit. + drop(ctrl); + }; + + // Drop `observer` inside the driver check after run_raw exits to verify `false` return value. + let check_closed = async move { + driver.await; + }; + drop(observer); + let (probe_ctrl, probe_rx) = ScrapeManager::control_channel(); + drop(probe_rx); + assert!(!probe_ctrl.update_targets(Vec::new())); + assert!(!probe_ctrl.shutdown()); + + tokio::join!(manager.run_raw(Vec::new(), ctrl_rx, &sink), check_closed); + + let seen = sink.seen.lock().expect("lock"); + let stamps = seen.get(t1.url().as_str()).expect("t1 scraped"); + assert!( + stamps[0].duration_since(start) >= Duration::from_secs(6), + "expected first scrape after 6s initial_delay, got {:?}", + stamps[0].duration_since(start) + ); +} + +#[tokio::test] +async fn real_http_concurrent_header_stalls_complete_in_parallel_with_bounded_buffers() { + let mut targets = Vec::new(); + let mut server_handles = Vec::new(); + #[cfg(feature = "arrow")] + let expected_passes = 2usize; + #[cfg(not(feature = "arrow"))] + let expected_passes = 1usize; + + let header_barrier = Arc::new(tokio::sync::Barrier::new(6)); + + for idx in 0..6 { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + targets.push(ScrapeTarget::parse(&format!("http://{addr}/metrics")).expect("target")); + let barrier = Arc::clone(&header_barrier); + + server_handles.push(tokio::spawn(async move { + for _ in 0..expected_passes { + let (mut sock, _) = listener.accept().await.expect("accept"); + let mut req = [0u8; 1024]; + let _ = sock.read(&mut req).await.expect("read req"); + + // All 6 requests must be in flight concurrently before any handler sends headers. + barrier.wait().await; + + let body = format!( + "# TYPE http_test_total counter\nhttp_test_total{{shard=\"{idx}\"}} {}\n", + idx + 1 + ); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + sock.write_all(resp.as_bytes()).await.expect("write resp"); + } + })); + } + + let cfg = ScrapeConfig::new("http-parallel") + .with_scrape_interval(Duration::from_secs(10)) + .with_scrape_timeout(Duration::from_secs(5)) + .with_scheduling(SchedulingMode::concurrent_with_pools( + NonZeroUsize::new(6).unwrap(), + NonZeroUsize::new(2).unwrap(), + NonZeroUsize::new(1).unwrap(), + )); + + let manager = ScrapeManager::with_reqwest(cfg).expect("reqwest manager"); + + let raw_sink = RecordingRawSink::new(); + let raw_cf = tokio::time::timeout( + Duration::from_secs(5), + manager.run_once_raw(&targets, &raw_sink), + ) + .await + .expect("6-way barrier must release under concurrent header scheduling"); + assert!(raw_cf.is_continue()); + assert_eq!(raw_sink.seen.lock().expect("lock").len(), 6); + + #[cfg(feature = "arrow")] + { + use prometheus_expfmt::arrow::OtapMetricRecords; + use prometheus_scrape_manager::scrape::{ArrowScrapeSink, ScrapeReport}; + + struct CountSink(Mutex>); + impl ArrowScrapeSink for CountSink { + async fn on_batch( + &self, + _target: &ScrapeTarget, + _ts_ms: i64, + _records: OtapMetricRecords, + ) -> ControlFlow<()> { + ControlFlow::Continue(()) + } + async fn on_scrape_complete( + &self, + _target: &ScrapeTarget, + report: ScrapeReport, + ) -> ControlFlow<()> { + self.0.lock().expect("lock").push(report); + ControlFlow::Continue(()) + } + } + + let sink = CountSink(Mutex::new(Vec::new())); + let arrow_cf = tokio::time::timeout( + Duration::from_secs(5), + manager.run_once_arrow(&targets, &sink), + ) + .await + .expect("6-way barrier must release under concurrent arrow scheduling"); + assert!(arrow_cf.is_continue()); + let reports = sink.0.lock().expect("lock"); + assert_eq!(reports.len(), 6); + assert!(reports.iter().all(|r| r.up && r.data_points_decoded == 1)); + } + + let stats = manager.stats(); + assert_eq!(stats.scrapes_succeeded, (6 * expected_passes) as u64); + assert!(stats.total_body_buffers_created <= 2); + #[cfg(feature = "arrow")] + assert_eq!(stats.total_decoders_created, 1); + + for h in server_handles { + h.await.expect("server task"); + } +} + +#[test] +fn non_send_local_set_compatibility_for_thread_per_core_runtimes() { + use std::cell::RefCell; + use std::rc::Rc; + + struct LocalRawSink { + seen: Rc>, + stop_at: usize, + } + + impl RawScrapeSink for LocalRawSink { + async fn on_raw_scrape( + &self, + _target: &ScrapeTarget, + result: Result, + ) -> ControlFlow<()> { + assert!(result.is_ok()); + let mut count = self.seen.borrow_mut(); + *count += 1; + if *count >= self.stop_at { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } + } + + #[cfg(feature = "arrow")] + use prometheus_expfmt::arrow::OtapMetricRecords; + #[cfg(feature = "arrow")] + use prometheus_scrape_manager::scrape::{ArrowScrapeSink, ScrapeReport}; + + // Rc> is !Send and !Sync, matching otel-arrow per-core LocalSet receivers. + #[cfg(feature = "arrow")] + struct LocalOnlySink { + points: Rc>, + batches: Rc>, + completed: Rc>, + stop_after: usize, + } + + #[cfg(feature = "arrow")] + impl ArrowScrapeSink for LocalOnlySink { + async fn on_batch( + &self, + _target: &ScrapeTarget, + _scrape_timestamp_ms: i64, + records: OtapMetricRecords, + ) -> ControlFlow<()> { + *self.points.borrow_mut() += records.num_data_points() as u64; + *self.batches.borrow_mut() += 1; + ControlFlow::Continue(()) + } + + async fn on_scrape_complete( + &self, + _target: &ScrapeTarget, + report: ScrapeReport, + ) -> ControlFlow<()> { + assert!(report.up); + let mut done = self.completed.borrow_mut(); + *done += 1; + if *done >= self.stop_after { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } + } + + struct LocalOnlyFetcher { + payload: Rc>, + } + + impl TargetFetcher for LocalOnlyFetcher { + async fn fetch( + &self, + _target: &ScrapeTarget, + pool: &BodyBufferPool, + ) -> Result { + let mut body = pool.acquire(self.payload.len()).await; + body.extend_from_slice(&self.payload); + Ok(FetchedScrape { + body, + format: Format::from(FMT_TEXT), + scrape_timestamp_ms: 1_700_000_000_000, + fetch_duration: Duration::from_micros(50), + wire_bytes: self.payload.len(), + }) + } + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .expect("current_thread runtime"); + let local = tokio::task::LocalSet::new(); + + local.block_on(&rt, async { + let mut text = String::from("# TYPE local_counter_total counter\n"); + for i in 0..500 { + text.push_str(&format!("local_counter_total{{id=\"{i}\"}} {i}\n")); + } + let fetcher = LocalOnlyFetcher { + payload: Rc::new(text.into_bytes()), + }; + let cfg = ScrapeConfig::new("local-core") + .with_scrape_interval(Duration::from_secs(5)) + .with_scrape_timeout(Duration::from_secs(2)) + .with_max_data_points_per_batch(200) + .with_scheduling(SchedulingMode::concurrent(NonZeroUsize::new(4).unwrap())); + let manager = ScrapeManager::new(cfg, fetcher).expect("manager"); + + let targets: Vec = (0..4) + .map(|i| ScrapeTarget::parse(&format!("http://127.0.0.1:900{i}/metrics")).unwrap()) + .collect(); + + // Exercise `!Send` `RawScrapeSink` on both `run_once_raw` and continuous `run_raw`. + let raw_sink = LocalRawSink { + seen: Rc::new(RefCell::new(0)), + stop_at: 8, + }; + let _ = manager.run_once_raw(&targets, &raw_sink).await; + assert_eq!(*raw_sink.seen.borrow(), 4); + let (_raw_ctrl, raw_rx) = ScrapeManager::control_channel(); + manager.run_raw(targets.clone(), raw_rx, &raw_sink).await; + assert!(*raw_sink.seen.borrow() >= 8); + + #[cfg(feature = "arrow")] + { + let sink = LocalOnlySink { + points: Rc::new(RefCell::new(0)), + batches: Rc::new(RefCell::new(0)), + completed: Rc::new(RefCell::new(0)), + stop_after: 8, + }; + + let _ = manager.run_once_arrow(&targets, &sink).await; + assert_eq!(*sink.points.borrow(), 4 * 500); + // 500 points chunked at 200 points/batch -> 3 chunks per target * 4 targets = 12 chunks. + assert_eq!(*sink.batches.borrow(), 12); + + let (_arrow_ctrl, arrow_rx) = ScrapeManager::control_channel(); + manager.run_arrow(targets, arrow_rx, &sink).await; + assert!(*sink.completed.borrow() >= 8); + + let stats = manager.stats(); + assert!(stats.total_decoders_created <= 2); + assert!(stats.total_body_buffers_created <= 2); + } + }); +} + +#[cfg(feature = "arrow")] +#[tokio::test(start_paused = true)] +async fn cooperative_arrow_decode_reports_partial_errors_reclaims_pools_on_break_and_runs_loop() { + use prometheus_expfmt::arrow::{DecodeBudget, OtapMetricRecords}; + use prometheus_scrape_manager::scrape::{ArrowScrapeSink, ScrapeReport}; + + enum StubMode { + OkText(Vec), + MissingEofOpenMetrics(Vec), + Http503, + } + + struct StubFetcher { + by_url: HashMap, + } + + impl TargetFetcher for StubFetcher { + async fn fetch( + &self, + target: &ScrapeTarget, + pool: &BodyBufferPool, + ) -> Result { + match self.by_url.get(target.url().as_str()).expect("stub url") { + StubMode::OkText(bytes) => { + let mut body = pool.acquire(bytes.len()).await; + body.extend_from_slice(bytes); + Ok(FetchedScrape { + body, + format: Format::TEXT, + scrape_timestamp_ms: 1_700_000_000_000, + fetch_duration: Duration::from_millis(1), + wire_bytes: bytes.len(), + }) + } + StubMode::MissingEofOpenMetrics(bytes) => { + let mut body = pool.acquire(bytes.len()).await; + body.extend_from_slice(bytes); + Ok(FetchedScrape { + body, + format: Format::OPEN_METRICS_1_0_0, + scrape_timestamp_ms: 1_700_000_000_000, + fetch_duration: Duration::from_millis(1), + wire_bytes: bytes.len(), + }) + } + StubMode::Http503 => Err(ScrapeError::UnexpectedStatus { status: 503 }), + } + } + } + + struct InspectSink { + break_on_first_batch: bool, + batches: Mutex, + reports: Mutex>, + } + + impl ArrowScrapeSink for InspectSink { + async fn on_batch( + &self, + _target: &ScrapeTarget, + _ts_ms: i64, + _records: OtapMetricRecords, + ) -> ControlFlow<()> { + *self.batches.lock().unwrap() += 1; + if self.break_on_first_batch { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } + + async fn on_scrape_complete( + &self, + _target: &ScrapeTarget, + report: ScrapeReport, + ) -> ControlFlow<()> { + self.reports.lock().unwrap().push(report); + ControlFlow::Continue(()) + } + } + + let ok_target = ScrapeTarget::parse("http://10.9.0.1:9100/metrics").unwrap(); + let bad_om_target = ScrapeTarget::parse("http://10.9.0.2:9100/metrics").unwrap(); + let down_target = ScrapeTarget::parse("http://10.9.0.3:9100/metrics").unwrap(); + + let mut ok_payload = String::from("# TYPE coop_metric gauge\n"); + for i in 0..25 { + ok_payload.push_str(&format!("coop_metric{{idx=\"{i}\"}} {i}\n")); + } + let mut missing_eof = String::from("# TYPE om_metric gauge\n"); + for i in 0..8 { + missing_eof.push_str(&format!("om_metric{{idx=\"{i}\"}} {i}\n")); + } + + let mut by_url = HashMap::new(); + by_url.insert( + ok_target.url().as_str().to_owned(), + StubMode::OkText(ok_payload.into_bytes()), + ); + by_url.insert( + bad_om_target.url().as_str().to_owned(), + StubMode::MissingEofOpenMetrics(missing_eof.into_bytes()), + ); + by_url.insert(down_target.url().as_str().to_owned(), StubMode::Http503); + + // Tight `DecodeBudget` (2 entries / 32 bytes) forces `DecodeStep::Pending` cooperative yields + // and sub-slice budget clamping on a single-buffer, single-decoder sequential manager. + let cfg = ScrapeConfig::new("coop-arrow") + .with_scrape_interval(Duration::from_secs(10)) + .with_scrape_timeout(Duration::from_secs(5)) + .with_max_data_points_per_batch(4) + .with_decode_budget(DecodeBudget::new(2, 32, Duration::from_millis(1))) + .with_scheduling(SchedulingMode::sequential()); + let manager = ScrapeManager::new(cfg, StubFetcher { by_url }).expect("manager"); + + // 1. Break on chunk 1 of a multi-chunk scrape must reclaim both single-slot pools cleanly. + let break_sink = InspectSink { + break_on_first_batch: true, + batches: Mutex::new(0), + reports: Mutex::new(Vec::new()), + }; + let brk = manager.scrape_once_arrow(&ok_target, &break_sink).await; + assert!(brk.is_break()); + + // 2. Subsequent scrapes succeed without deadlocking on the 1-slot body/decoder pools, and + // mid-stream OpenMetrics decode failure reports the 2 tentative chunks emitted before EOF. + let sink = InspectSink { + break_on_first_batch: false, + batches: Mutex::new(0), + reports: Mutex::new(Vec::new()), + }; + let ok_rep = match manager.scrape_once_arrow(&ok_target, &sink).await { + ControlFlow::Continue(r) => r, + ControlFlow::Break(()) => panic!("unexpected break"), + }; + assert!(ok_rep.up); + assert_eq!(ok_rep.data_points_decoded, 25); + assert_eq!(ok_rep.batches_emitted, 7); + + let om_rep = match manager.scrape_once_arrow(&bad_om_target, &sink).await { + ControlFlow::Continue(r) => r, + ControlFlow::Break(()) => panic!("unexpected break"), + }; + assert!(!om_rep.up); + assert_eq!(om_rep.data_points_decoded, 8); + assert_eq!(om_rep.batches_emitted, 2); + assert!(om_rep.error.is_some()); + + let down_rep = match manager.scrape_once_arrow(&down_target, &sink).await { + ControlFlow::Continue(r) => r, + ControlFlow::Break(()) => panic!("unexpected break"), + }; + assert!(!down_rep.up); + assert_eq!(down_rep.batches_emitted, 0); + + // 3. Drive continuous `run_arrow` across one interval tick and shut down cleanly. + let (ctrl, ctrl_rx) = ScrapeManager::control_channel(); + let driver = async move { + tokio::time::sleep(Duration::from_secs(5)).await; + let _ = ctrl.shutdown(); + }; + tokio::join!(manager.run_arrow(vec![ok_target], ctrl_rx, &sink), driver); + + let stats = manager.stats(); + assert_eq!(stats.total_body_buffers_created, 1); + assert_eq!(stats.total_decoders_created, 1); + assert_eq!(stats.scrapes_succeeded, 2); + assert_eq!(stats.scrapes_failed, 2); +} diff --git a/specs/SPEC.md b/specs/SPEC.md index 6becd52..c23feb3 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -167,6 +167,16 @@ pub fn arrow_to_metric_families( ) -> Result, DecodeError>; ``` +### Bounded scrape scheduling and memory pools + +`prometheus-scrape-manager` (`crates/scrape-manager`) schedules periodic HTTP scrapes and feeds wire payloads into `ArrowDecoder` with bounded scratch memory. Go Prometheus spawns one goroutine, `scrapeCache`, and `gzip.Reader` per target in [`scrape/scrape.go`](https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/scrape.go#L510-L584) and pulls from an unbounded `sync.Pool` buffer allocator in [`scrapeAndReport`](https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/scrape.go#L1568-L1575), so working-set memory grows when many targets return responses at the same time. `ScrapeManager` decouples schedule concurrency from buffer and decoder residency: + +- `TargetSchedule` stores all active targets in a single `BinaryHeap` rather than one task per target. Each target receives a deterministic phase offset `fnv1a_64(jitter_seed, url, sorted_labels) % scrape_interval`, mirroring [`Target.offset`](https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/target.go#L170-L184). Popping a due target advances its next grid tick, `due + k * scrape_interval`, right away so scrape latency does not shift future intervals. If a previous scrape of the same target is still in flight when its next tick arrives, the scheduler skips that tick and increments `scrapes_overrun`. Updating targets preserves existing phase offsets for unchanged targets. +- `SchedulingMode::Concurrent` separates three limits: `max_in_flight` bounds simultaneous HTTP requests waiting on TCP connect and response headers, `max_buffered_bodies` bounds reusable `Vec` response buffers in `BodyBufferPool`, and `max_decoders` bounds reusable `ArrowDecoder` instances in `DecoderPool`. A slow target waiting on server-side `/metrics` generation holds zero payload buffers and zero decoders until HTTP `200 OK` headers pass `Content-Length <= max_body_bytes`. Any buffer that grows past `max_retained_body_bytes` on an outlier target shrinks back to `max_retained_body_bytes` when returned to `BodyBufferPool`, and any `ArrowDecoder` whose scratch capacity exceeds `max_retained_decoder_bytes` resets on return to `DecoderPool`. +- `SchedulingMode::Sequential` remains available as a first-class option for callers that want strict one-at-a-time scraping with `1` body buffer and `1` decoder, using either a job-wide cycle tick via `SequentialSchedule::CycleTick` or per-target FNV-1a offsets via `SequentialSchedule::Jittered`. +- `ReqwestFetcher` sends `Accept`, `Accept-Encoding: gzip`, `User-Agent`, and `X-Prometheus-Scrape-Timeout-Seconds` headers matching [`targetScraper.scrape`](https://github.com/prometheus/prometheus/blob/v0.315.1/scrape/scrape.go#L824-L836), and streams compressed or plain chunks with a hard `max_body_bytes` cap on uncompressed output so oversized responses abort mid-stream. +- `ScrapeManager` drives concurrent scrapes via `FuturesUnordered` without `tokio::spawn`, keeping `ArrowScrapeSink` and `RawScrapeSink` free of `Send` bounds for thread-per-core `LocalSet` receivers in `open-telemetry/otel-arrow`. Chunks delivered to `ArrowScrapeSink::on_batch` are tentative until `ArrowScrapeSink::on_scrape_complete` observes `report.up == true`; if decoding fails mid-stream, `ScrapeReport` records `up == false` alongside the partial `batches_emitted` count so transactional sinks can discard staged chunks like Go `app.Rollback()`. + ## Testing and verification ### Test corpus from `prometheus/prometheus` and `prometheus/common`