diff --git a/.gitignore b/.gitignore index 20b91f6b..6368ac41 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ _manifest ARROW # Agent files -.claude \ No newline at end of file +.claude +CLAUDE.md \ No newline at end of file diff --git a/.spelling b/.spelling index e9ceb2a8..c17aa392 100644 --- a/.spelling +++ b/.spelling @@ -1,3 +1,4 @@ +304 → 0.X.Y 100k @@ -37,6 +38,7 @@ btree_map buildable bytesbuf callee +cancelled Cargo.toml C-BITFLAG C-CONV @@ -81,6 +83,8 @@ C-SERDE C-SMART-PTR deallocate Debuggability +Deduplicate +deduplicating deduplication deque Deque @@ -273,6 +277,8 @@ unconfigured uncontended unhandleable unicode +uniflight +uniflight's Uninit unordered unredacted diff --git a/CHANGELOG.md b/CHANGELOG.md index 9862cfa6..64a2fc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Please see each crate's change log below: +- [`async_once`](./crates/async_once/CHANGELOG.md) - [`bytesbuf`](./crates/bytesbuf/CHANGELOG.md) - [`bytesbuf_io`](./crates/bytesbuf_io/CHANGELOG.md) - [`data_privacy`](./crates/data_privacy/CHANGELOG.md) @@ -18,3 +19,4 @@ Please see each crate's change log below: - [`thread_aware_macros`](./crates/thread_aware_macros/CHANGELOG.md) - [`thread_aware_macros_impl`](./crates/thread_aware_macros_impl/CHANGELOG.md) - [`tick`](./crates/tick/CHANGELOG.md) +- [`uniflight`](./crates/uniflight/CHANGELOG.md) diff --git a/Cargo.lock b/Cargo.lock index 0022259b..4239b9ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "alloc_tracker" -version = "0.5.12" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "541f3dfa28da9fba29d46a58d1fc137d401b7cdd5fff76c2cdc03abe3ad5d87c" +checksum = "6d6f8b0cbd2617bdb93d237d7e11032f0ec3ea509ce1fbd351397567e36dd6d6" [[package]] name = "android_system_properties" @@ -38,6 +38,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + [[package]] name = "autocfg" version = "1.5.0" @@ -119,9 +125,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.51" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "shlex", @@ -135,9 +141,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "num-traits", @@ -183,18 +189,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstyle", "clap_lex", @@ -202,9 +208,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "convert_case" @@ -223,13 +229,13 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpulist" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11a45acbbf4646bcff1e38cfd1f7d88ae73353d4add7ca31609023216599bbfa" +checksum = "10921f832bb6e2075c6910ab0be7b33e413029ae6d74b01f049fc6cb237362e6" dependencies = [ "itertools 0.14.0", "new_zealand", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -250,6 +256,7 @@ dependencies = [ "serde", "serde_json", "tinytemplate", + "tokio", "walkdir", ] @@ -263,12 +270,32 @@ dependencies = [ "itertools 0.13.0", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data_privacy" version = "0.10.1" @@ -405,9 +432,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "foldhash" @@ -417,9 +444,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "folo_ffi" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44b0401f7b448a28be3e723e6a30cd29ebe707ebbf23ac01ab505da0de10005c" +checksum = "b2c4a9efbd06c6c8500f5d97a20159996679088bdb5f598a863960bedf3f2574" [[package]] name = "fragile" @@ -582,6 +609,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -624,19 +657,19 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] name = "infinity_pool" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f81221d9c547a962679935ef10f3be03739f8a99c98ad7bfc1fcbd4850892bf" +checksum = "1b53f0294af2d25dc8eee1d483579ad476233efe0ce1e09c1721275a976d7055" dependencies = [ "new_zealand", "num-integer", @@ -646,9 +679,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.45.1" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "983e3b24350c84ab8a65151f537d67afbbf7153bb9f1110e03e9fa9b07f67a5c" +checksum = "248b42847813a1550dafd15296fd9748c651d0c32194559dbc05d804d54b21e8" dependencies = [ "once_cell", "serde", @@ -682,9 +715,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -697,9 +730,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", @@ -723,9 +756,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -756,9 +789,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "linux-raw-sys" @@ -783,9 +816,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "many_cpus" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb82f238dc324f74bb9c2e25b4244dbdeab8d66809dc3e988472498e84a411ec" +checksum = "d4ab614fc8e14ffa2aab66ed7942362b33191752f3a5bf9533e9f88e4204545f" dependencies = [ "cpulist", "derive_more", @@ -853,15 +886,15 @@ dependencies = [ [[package]] name = "new_zealand" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62cc3bfd31ca5136b8449b5418a91a9c2a336b3ea61ae17eae350c10bd4ab26d" +checksum = "e098f45dda35490b475350685c55a9fb4c4f6cb885f6f0b09fea301cfef52161" [[package]] name = "nm" -version = "0.1.22" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e593410e637cfb2542a21f30106fde38fcf7bb8359b39c1355d382ad09f888" +checksum = "93ee800dec1976af03cfa4c209e0fac85e140266782719f06bddfd321177acf9" dependencies = [ "foldhash", "new_zealand", @@ -915,7 +948,7 @@ dependencies = [ "mutants", "ohno_macros", "regex", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "typeid", ] @@ -1128,18 +1161,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1166,7 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -1176,7 +1209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -1187,18 +1220,18 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom", ] [[package]] name = "rapidhash" -version = "4.2.0" +version = "4.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2988730ee014541157f48ce4dcc603940e00915edc3c7f9a8d78092256bb2493" +checksum = "5d8b5b858a440a0bc02625b62dd95131b9201aa9f69f411195dd4a7cfb1de3d7" dependencies = [ "rustversion", ] @@ -1348,9 +1381,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -1437,9 +1470,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -1508,11 +1541,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -1528,9 +1561,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1600,9 +1633,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -1611,22 +1644,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -1663,9 +1696,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "pin-project-lite", "tokio-macros", @@ -1684,9 +1717,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -1697,9 +1730,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", @@ -1736,9 +1769,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -1843,6 +1876,20 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uniflight" +version = "0.1.0" +dependencies = [ + "async-once-cell", + "criterion", + "dashmap", + "futures-util", + "mutants", + "thread_aware", + "tick", + "tokio", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1855,18 +1902,18 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -1877,9 +1924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1887,9 +1934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -1900,9 +1947,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -2160,9 +2207,9 @@ checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "xml-rs" @@ -2178,18 +2225,18 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", @@ -2198,6 +2245,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.3" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9747e91771f56fd7893e1164abd78febd14a670ceec257caad15e051de35f06" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/Cargo.toml b/Cargo.toml index 26dcf70c..d19271cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,13 +41,17 @@ thread_aware = { path = "crates/thread_aware", default-features = false, version thread_aware_macros = { path = "crates/thread_aware_macros", default-features = false, version = "0.6.1" } thread_aware_macros_impl = { path = "crates/thread_aware_macros_impl", default-features = false, version = "0.6.1" } tick = { path = "crates/tick", default-features = false, version = "0.1.2" } +uniflight = { path = "crates/uniflight", default-features = false, version = "0.1.0" } # external dependencies alloc_tracker = { version = "0.5.9", default-features = false } +anyhow = { version = "1.0.100", default-features = false } +async-once-cell = { version = "0.5", default-features = false } bytes = { version = "1.11.0", default-features = false } chrono = { version = "0.4.40", default-features = false } chrono-tz = { version = "0.10.4", default-features = false } criterion = { version = "0.7.0", default-features = false } +dashmap = { version = "6.1", default-features = false } derive_more = { version = "2.0.1", default-features = false } duct = { version = "1.1.1", default-features = false } dynosaur = { version = "0.3.0", default-features = false } @@ -65,6 +69,7 @@ new_zealand = { version = "1.0.1", default-features = false } nm = { version = "0.1.21", default-features = false } num-traits = { version = "0.2.19", default-features = false } once_cell = { version = "1.21.3", default-features = false } +parking_lot = { version = "0.12.5", default-features = false } pin-project-lite = { version = "0.2.13", default-features = false } pretty_assertions = { version = "1.4.1", default-features = false } prettyplease = { version = "0.2.37", default-features = false } @@ -93,6 +98,7 @@ trait-variant = { version = "0.1.2", default-features = false } trybuild = { version = "1.0.114", default-features = false } typeid = { version = "1.0.3", default-features = false } windows-sys = { version = "0.61.2", default-features = false } +xutex = { version = "0.2.0", default-features = false } xxhash-rust = { version = "0.8.15", default-features = false } [workspace.lints.rust] diff --git a/README.md b/README.md index 0085af2e..0e17a57c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ These are the primary crates built out of this repo: - [`recoverable`](./crates/recoverable/README.md) - Recovery information and classification for resilience patterns. - [`thread_aware`](./crates/thread_aware/README.md) - Facilities to support thread-isolated state. - [`tick`](./crates/tick/README.md) - Provides primitives to interact with and manipulate machine time. +- [`uniflight`](./crates/uniflight/README.md) - Coalesces duplicate async tasks into a single execution. ## About This Repo diff --git a/crates/uniflight/CHANGELOG.md b/crates/uniflight/CHANGELOG.md new file mode 100644 index 00000000..0906fd27 --- /dev/null +++ b/crates/uniflight/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [0.1.0] - 2025-12-10 + +- 🧩 Miscellaneous + + - Initial commit of uniflight + diff --git a/crates/uniflight/Cargo.toml b/crates/uniflight/Cargo.toml new file mode 100644 index 00000000..29773101 --- /dev/null +++ b/crates/uniflight/Cargo.toml @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "uniflight" +description = "Coalesces multiple ongoing tasks into a leader which does the work, and follower tasks that wait on the result, to prevent duplicate I/O or other downstream overhead." +version = "0.1.0" +readme = "README.md" +keywords = ["oxidizer", "coalescing", "stempede", "singleflight", "deduplication"] +categories = ["concurrency"] + +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[package.metadata.cargo_check_external_types] +allowed_external_types = [ + "thread_aware::cell::builtin::PerProcess", + "thread_aware::cell::storage::Strategy", + "thread_aware::core::ThreadAware", +] + +[dependencies] +async-once-cell.workspace = true +dashmap.workspace = true +thread_aware.workspace = true + +[dev-dependencies] +criterion = { workspace = true, features = ["async_tokio"] } +futures-util = { workspace = true, features = ["alloc", "std"] } +mutants.workspace = true +tick = { workspace = true, features = ["tokio"] } +tokio = { workspace = true, features = [ + "macros", + "rt", + "time", + "rt-multi-thread", +] } + +[lints] +workspace = true + +[[bench]] +name = "performance" +harness = false + +[[example]] +name = "cache_population" diff --git a/crates/uniflight/README.md b/crates/uniflight/README.md new file mode 100644 index 00000000..24829c1c --- /dev/null +++ b/crates/uniflight/README.md @@ -0,0 +1,120 @@ +
+ Uniflight Logo + +# Uniflight + +[![crate.io](https://img.shields.io/crates/v/uniflight.svg)](https://crates.io/crates/uniflight) +[![docs.rs](https://docs.rs/uniflight/badge.svg)](https://docs.rs/uniflight) +[![MSRV](https://img.shields.io/crates/msrv/uniflight)](https://crates.io/crates/uniflight) +[![CI](https://github.com/microsoft/oxidizer/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/oxidizer/actions/workflows/main.yml) +[![Coverage](https://codecov.io/gh/microsoft/oxidizer/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/oxidizer) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) +This crate was developed as part of the Oxidizer project + +
+ +Coalesces duplicate async tasks into a single execution. + +This crate provides [`Merger`][__link0], a mechanism for deduplicating concurrent async operations. +When multiple tasks request the same work (identified by a key), only the first task (the +“leader”) performs the actual work while subsequent tasks (the “followers”) wait and receive +a clone of the result. + +## When to Use + +Use `Merger` when you have expensive or rate-limited operations that may be requested +concurrently with the same parameters: + +* **Cache population**: Prevent thundering herd when a cache entry expires +* **API calls**: Deduplicate concurrent requests to the same endpoint +* **Database queries**: Coalesce identical queries issued simultaneously +* **File I/O**: Avoid reading the same file multiple times concurrently + +## Example + +```rust +use uniflight::Merger; + +let group: Merger = Merger::new(); + +// Multiple concurrent calls with the same key will share a single execution. +// Note: you can pass &str directly when the key type is String. +let result = group.execute("user:123", || async { + // This expensive operation runs only once, even if called concurrently + "expensive_result".to_string() +}).await; +``` + +## Flexible Key Types + +The [`Merger::execute`][__link1] method accepts keys using [`Borrow`][__link2] semantics, allowing you to pass +borrowed forms of the key type. For example, with `Merger`, you can pass `&str` +directly without allocating: + +```rust +let merger: Merger = Merger::new(); + +// Pass &str directly - no need to call .to_string() +merger.execute("my-key", || async { 42 }).await; +``` + +## Thread-Aware Scoping + +`Merger` supports thread-aware scoping via a [`Strategy`][__link3] +type parameter. This controls how the internal state is partitioned across threads/NUMA nodes: + +* [`PerProcess`][__link4] (default): Single global state, maximum deduplication +* [`PerNuma`][__link5]: Separate state per NUMA node, NUMA-local memory access +* [`PerCore`][__link6]: Separate state per core, no deduplication (useful for already-partitioned work) + +```rust +use uniflight::Merger; +use thread_aware::PerNuma; + +// NUMA-aware merger - each NUMA node gets its own deduplication scope +let merger: Merger = Merger::new_per_numa(); +``` + +## Cancellation and Panic Safety + +`Merger` handles task cancellation and panics gracefully: + +* If the leader task is cancelled or dropped, a follower becomes the new leader +* If the leader task panics, a follower becomes the new leader and executes its work +* Followers that join before the leader completes receive the cached result + +## Memory Management + +Completed entries are automatically removed from the internal map when the last caller +finishes. This ensures no stale entries accumulate over time. + +## Thread Safety + +[`Merger`][__link7] is `Send` and `Sync`, and can be shared across threads. The returned futures +are `Send` when the closure, future, key, and value types are `Send`. + +## Performance + +Run benchmarks with `cargo bench -p uniflight`. The suite covers: + +* **single_call**: Baseline latency with no contention +* **high_contention_100**: 100 concurrent tasks on the same key +* **distributed_10x10**: 10 keys with 10 tasks each + +Use `--save-baseline` and `--baseline` flags to track regressions over time. + + +
+ +This crate was developed as part of The Oxidizer Project. Browse this crate's source code. + + + [__cargo_doc2readme_dependencies_info]: ggGkYW0CYXSEGy4k8ldDFPOhG2VNeXtD5nnKG6EPY6OfW5wBG8g18NOFNdxpYXKEG7377t2ZpTeEGzAOjWIcwZYsG8xBz1ZJEGjgG3_fpjW3KImqYWSCgmx0aHJlYWRfYXdhcmVlMC42LjGCaXVuaWZsaWdodGUwLjEuMA + [__link0]: https://docs.rs/uniflight/0.1.0/uniflight/struct.Merger.html + [__link1]: https://docs.rs/uniflight/0.1.0/uniflight/?search=Merger::execute + [__link2]: https://doc.rust-lang.org/stable/std/?search=borrow::Borrow + [__link3]: https://docs.rs/thread_aware/0.6.1/thread_aware/?search=storage::Strategy + [__link4]: https://docs.rs/thread_aware/0.6.1/thread_aware/?search=PerProcess + [__link5]: https://docs.rs/thread_aware/0.6.1/thread_aware/?search=PerNuma + [__link6]: https://docs.rs/thread_aware/0.6.1/thread_aware/?search=PerCore + [__link7]: https://docs.rs/uniflight/0.1.0/uniflight/struct.Merger.html diff --git a/crates/uniflight/benches/performance.rs b/crates/uniflight/benches/performance.rs new file mode 100644 index 00000000..c96574c5 --- /dev/null +++ b/crates/uniflight/benches/performance.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Performance benchmarks for uniflight. +//! +//! Run with: cargo bench -p uniflight +//! Save baseline: cargo bench -p uniflight -- --save-baseline main +//! Compare to baseline: cargo bench -p uniflight -- --baseline main + +#![allow(missing_docs)] + +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use criterion::{Criterion, criterion_group, criterion_main}; +use uniflight::Merger; + +static KEY_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn unique_key() -> String { + format!("key_{}", KEY_COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +/// Baseline: single call, no contention. +/// This measures the fixed overhead of the merger. +fn bench_single_call(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let merger = Arc::new(Merger::::new()); + + c.bench_function("single_call", |b| { + b.to_async(&rt).iter(|| { + let merger = Arc::clone(&merger); + async move { merger.execute(&unique_key(), || async { "value".to_string() }).await } + }); + }); +} + +/// Stress test: 100 concurrent tasks on the same key. +/// This hammers the synchronization primitives. +fn bench_high_contention(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let merger = Arc::new(Merger::::new()); + + c.bench_function("high_contention_100", |b| { + b.to_async(&rt).iter(|| { + let merger = Arc::clone(&merger); + async move { + let key = unique_key(); + let tasks: Vec<_> = (0..100) + .map(|_| { + let merger = Arc::clone(&merger); + let key = key.clone(); + tokio::spawn(async move { merger.execute(&key, || async { "value".to_string() }).await }) + }) + .collect(); + + for task in tasks { + task.await.unwrap(); + } + } + }); + }); +} + +/// Distributed load: 10 keys with 10 concurrent tasks each. +/// This exercises the hash map under concurrent access. +fn bench_distributed_keys(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let merger = Arc::new(Merger::::new()); + + c.bench_function("distributed_10x10", |b| { + b.to_async(&rt).iter(|| { + let merger = Arc::clone(&merger); + async move { + let prefix = KEY_COUNTER.fetch_add(1, Ordering::Relaxed); + let tasks: Vec<_> = (0..10) + .flat_map(|key_id| { + let merger = Arc::clone(&merger); + (0..10).map(move |_| { + let merger = Arc::clone(&merger); + let key = format!("key_{prefix}_{key_id}"); + tokio::spawn(async move { merger.execute(&key, || async { "value".to_string() }).await }) + }) + }) + .collect(); + + for task in tasks { + task.await.unwrap(); + } + } + }); + }); +} + +criterion_group!(benches, bench_single_call, bench_high_contention, bench_distributed_keys,); + +criterion_main!(benches); diff --git a/crates/uniflight/examples/cache_population.rs b/crates/uniflight/examples/cache_population.rs new file mode 100644 index 00000000..0e113863 --- /dev/null +++ b/crates/uniflight/examples/cache_population.rs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Demonstrates using `UniFlight` to prevent thundering herd when populating a cache. +//! +//! Multiple concurrent requests for the same cache key will share a single execution, +//! with the first request (leader) performing the work and subsequent requests (followers) +//! receiving a copy of the result. + +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use tick::Clock; +use uniflight::Merger; + +#[tokio::main] +async fn main() { + // Create a shared UniFlight instance for cache operations + let cache_group = Arc::new(Merger::::new()); + + // Track how many times the work closure actually executes + let execution_count = Arc::new(AtomicUsize::new(0)); + + println!("Starting 5 concurrent requests for user:123...\n"); + + // Simulate 5 concurrent requests for the same user data + let mut handles = Vec::new(); + for i in 1..=5 { + let group = Arc::clone(&cache_group); + let counter = Arc::clone(&execution_count); + let handle = tokio::spawn(async move { + let clock = Clock::new_tokio(); + let start = clock.instant(); + + let result = group + .execute("user:123", || async { + let count = counter.fetch_add(1, Ordering::SeqCst) + 1; + println!(" [Request {i}] I'm the leader! Fetching from database... (execution #{count})"); + + // Simulate expensive database query + clock.delay(Duration::from_millis(500)).await; + + "UserData(name: Alice, age: 30)".to_string() + }) + .await; + + let elapsed = start.elapsed(); + println!(" [Request {i}] Got result in {elapsed:?}: {result}"); + }); + + handles.push(handle); + + // Stagger the requests slightly to see the deduplication in action + let clock = Clock::new_tokio(); + clock.delay(Duration::from_millis(10)).await; + } + + // Wait for all requests to complete + for handle in handles { + handle.await.expect("Task panicked"); + } + + let total_executions = execution_count.load(Ordering::SeqCst); + println!("\nAll requests completed! Database query executed {total_executions} time(s) for 5 requests."); +} diff --git a/crates/uniflight/favicon.ico b/crates/uniflight/favicon.ico new file mode 100644 index 00000000..f1a3f34c --- /dev/null +++ b/crates/uniflight/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed616c6fc5b1c95300147e226f28d1b88f193babdf3bb1669422a93f55339304 +size 15406 diff --git a/crates/uniflight/logo.png b/crates/uniflight/logo.png new file mode 100644 index 00000000..1562ae9f --- /dev/null +++ b/crates/uniflight/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6bf624b54edbaeb8bf0d961e895a86e09b18502fe6c761d00748317883dd09b8 +size 62560 diff --git a/crates/uniflight/src/lib.rs b/crates/uniflight/src/lib.rs new file mode 100644 index 00000000..987b4c2e --- /dev/null +++ b/crates/uniflight/src/lib.rs @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Coalesces duplicate async tasks into a single execution. +//! +//! This crate provides [`Merger`], a mechanism for deduplicating concurrent async operations. +//! When multiple tasks request the same work (identified by a key), only the first task (the +//! "leader") performs the actual work while subsequent tasks (the "followers") wait and receive +//! a clone of the result. +//! +//! # When to Use +//! +//! Use `Merger` when you have expensive or rate-limited operations that may be requested +//! concurrently with the same parameters: +//! +//! - **Cache population**: Prevent thundering herd when a cache entry expires +//! - **API calls**: Deduplicate concurrent requests to the same endpoint +//! - **Database queries**: Coalesce identical queries issued simultaneously +//! - **File I/O**: Avoid reading the same file multiple times concurrently +//! +//! # Example +//! +//! ``` +//! use uniflight::Merger; +//! +//! # async fn example() { +//! let group: Merger = Merger::new(); +//! +//! // Multiple concurrent calls with the same key will share a single execution. +//! // Note: you can pass &str directly when the key type is String. +//! let result = group.execute("user:123", || async { +//! // This expensive operation runs only once, even if called concurrently +//! "expensive_result".to_string() +//! }).await; +//! # } +//! ``` +//! +//! # Flexible Key Types +//! +//! The [`Merger::execute`] method accepts keys using [`Borrow`] semantics, allowing you to pass +//! borrowed forms of the key type. For example, with `Merger`, you can pass `&str` +//! directly without allocating: +//! +//! ``` +//! # use uniflight::Merger; +//! # async fn example() { +//! let merger: Merger = Merger::new(); +//! +//! // Pass &str directly - no need to call .to_string() +//! merger.execute("my-key", || async { 42 }).await; +//! # } +//! ``` +//! +//! # Thread-Aware Scoping +//! +//! `Merger` supports thread-aware scoping via a [`Strategy`] +//! type parameter. This controls how the internal state is partitioned across threads/NUMA nodes: +//! +//! - [`PerProcess`] (default): Single global state, maximum deduplication +//! - [`PerNuma`]: Separate state per NUMA node, NUMA-local memory access +//! - [`PerCore`]: Separate state per core, no deduplication (useful for already-partitioned work) +//! +//! ``` +//! use uniflight::Merger; +//! use thread_aware::PerNuma; +//! +//! # async fn example() { +//! // NUMA-aware merger - each NUMA node gets its own deduplication scope +//! let merger: Merger = Merger::new_per_numa(); +//! # } +//! ``` +//! +//! # Cancellation and Panic Safety +//! +//! `Merger` handles task cancellation and panics gracefully: +//! +//! - If the leader task is cancelled or dropped, a follower becomes the new leader +//! - If the leader task panics, a follower becomes the new leader and executes its work +//! - Followers that join before the leader completes receive the cached result +//! +//! # Memory Management +//! +//! Completed entries are automatically removed from the internal map when the last caller +//! finishes. This ensures no stale entries accumulate over time. +//! +//! # Thread Safety +//! +//! [`Merger`] is `Send` and `Sync`, and can be shared across threads. The returned futures +//! are `Send` when the closure, future, key, and value types are `Send`. +//! +//! # Performance +//! +//! Run benchmarks with `cargo bench -p uniflight`. The suite covers: +//! +//! - `single_call`: Baseline latency with no contention +//! - `high_contention_100`: 100 concurrent tasks on the same key +//! - `distributed_10x10`: 10 keys with 10 tasks each +//! +//! Use `--save-baseline` and `--baseline` flags to track regressions over time. + +#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/uniflight/logo.png")] +#![doc(html_favicon_url = "https://media.githubusercontent.com/media/microsoft/oxidizer/refs/heads/main/crates/uniflight/favicon.ico")] + +use std::{ + borrow::Borrow, + fmt::Debug, + hash::Hash, + sync::{Arc, Weak}, +}; + +use async_once_cell::OnceCell; +use dashmap::{ + DashMap, + Entry::{Occupied, Vacant}, +}; +use thread_aware::{ + Arc as TaArc, PerCore, PerNuma, PerProcess, ThreadAware, + affinity::{MemoryAffinity, PinnedAffinity}, + storage::Strategy, +}; + +/// Suppresses duplicate async operations identified by a key. +/// +/// The `S` type parameter controls the thread-aware scoping strategy: +/// - [`PerProcess`]: Single global scope (default, maximum deduplication) +/// - [`PerNuma`]: Per-NUMA-node scope (NUMA-local memory access) +/// - [`PerCore`]: Per-core scope (no deduplication) +pub struct Merger { + inner: TaArc>>, S>, +} + +impl Debug for Merger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Merger").field("inner", &format_args!("DashMap<...>")).finish() + } +} + +impl Clone for Merger { + fn clone(&self) -> Self { + Self { inner: self.inner.clone() } + } +} + +impl Default for Merger +where + K: Hash + Eq + Send + Sync + 'static, + T: Clone + Send + Sync + 'static, + S: Strategy + Send + Sync, +{ + fn default() -> Self { + Self { + inner: TaArc::new(DashMap::new), + } + } +} + +impl Merger +where + K: Hash + Eq + Send + Sync + 'static, + T: Clone + Send + Sync + 'static, + S: Strategy + Send + Sync, +{ + /// Creates a new `Merger` instance. + /// + /// The scoping strategy is determined by the type parameter `S`: + /// - [`PerProcess`] (default): Process-wide scope, maximum deduplication + /// - [`PerNuma`]: Per-NUMA-node scope, NUMA-local memory access + /// - [`PerCore`]: Per-core scope, no cross-core deduplication + /// + /// # Examples + /// + /// ``` + /// use uniflight::Merger; + /// use thread_aware::{PerNuma, PerCore}; + /// + /// // Default (PerProcess) - type can be inferred + /// let global: Merger = Merger::new(); + /// + /// // NUMA-local scope + /// let numa: Merger = Merger::new(); + /// + /// // Per-core scope + /// let core: Merger = Merger::new(); + /// ``` + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl Merger +where + K: Hash + Eq + Send + Sync + 'static, + T: Clone + Send + Sync + 'static, +{ + /// Creates a new `Merger` with process-wide scoping (default). + /// + /// All threads share a single deduplication scope, providing maximum + /// work deduplication across the entire process. + /// + /// # Example + /// + /// ``` + /// use uniflight::Merger; + /// + /// let merger = Merger::::new_per_process(); + /// ``` + #[inline] + #[must_use] + #[cfg_attr(test, mutants::skip)] // Equivalent mutant: delegates to Default + pub fn new_per_process() -> Self { + Self::default() + } +} + +impl Merger +where + K: Hash + Eq + Send + Sync + 'static, + T: Clone + Send + Sync + 'static, +{ + /// Creates a new `Merger` with per-NUMA-node scoping. + /// + /// Each NUMA node gets its own deduplication scope, ensuring memory + /// locality for cached results while still deduplicating within each node. + /// + /// # Example + /// + /// ``` + /// use uniflight::Merger; + /// + /// let merger = Merger::::new_per_numa(); + /// ``` + #[inline] + #[must_use] + #[cfg_attr(test, mutants::skip)] // Equivalent mutant: delegates to Default + pub fn new_per_numa() -> Self { + Self::default() + } +} + +impl Merger +where + K: Hash + Eq + Send + Sync + 'static, + T: Clone + Send + Sync + 'static, +{ + /// Creates a new `Merger` with per-core scoping. + /// + /// Each core gets its own deduplication scope. This is useful when work + /// is already partitioned by core and cross-core deduplication is not needed. + /// + /// # Example + /// + /// ``` + /// use uniflight::Merger; + /// + /// let merger = Merger::::new_per_core(); + /// ``` + #[inline] + #[must_use] + #[cfg_attr(test, mutants::skip)] // Equivalent mutant: delegates to Default + pub fn new_per_core() -> Self { + Self::default() + } +} + +impl Merger +where + K: Hash + Eq, +{ + /// Returns the number of in-flight operations. + #[cfg(test)] + fn len(&self) -> usize { + self.inner.len() + } + + /// Returns `true` if there are no in-flight operations. + #[cfg(test)] + fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +impl ThreadAware for Merger +where + S: Strategy, +{ + fn relocated(self, source: MemoryAffinity, destination: PinnedAffinity) -> Self { + Self { + inner: self.inner.relocated(source, destination), + } + } +} + +impl Merger +where + K: Hash + Eq + Send + Sync, + T: Clone + Send + Sync, + S: Strategy + Send + Sync, +{ + /// Execute and return the value for a given function, making sure that only one + /// operation is in-flight at a given moment. If a duplicate call comes in, + /// that caller will wait until the leader completes and return the same value. + /// + /// The key can be passed as any borrowed form of `K`. For example, if `K` is `String`, + /// you can pass `&str` directly: + /// + /// ``` + /// # use uniflight::Merger; + /// # async fn example() { + /// let merger: Merger = Merger::new(); + /// let result = merger.execute("my-key", || async { 42 }).await; + /// # } + /// ``` + pub fn execute(&self, key: &Q, func: F) -> impl Future + Send + use + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + F: FnOnce() -> Fut + Send, + Fut: Future + Send, + { + // Clone the TaArc - the async block owns this clone + let inner = self.inner.clone(); + let cell = Self::get_or_create_cell(&inner, key); + let owned_key = key.to_owned(); + async move { + let result = cell.get_or_init(func()).await.clone(); + drop(cell); // Release our Arc before cleanup check + // Remove entry if no one else is using it (weak can't upgrade) + inner.remove_if(owned_key.borrow(), |_, weak| weak.upgrade().is_none()); + result + } + } + + /// Gets an existing `OnceCell` for the key, or creates a new one. + fn get_or_create_cell(map: &DashMap>>, key: &Q) -> Arc> + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + { + // Fast path: check if entry exists and is still valid + if let Some(entry) = map.get(key) + && let Some(cell) = entry.value().upgrade() + { + return cell; + } + + // Slow path: need to insert or replace expired entry + Self::insert_or_get_existing(map, key) + } + + /// Inserts a new cell or returns an existing live cell (handling races). + /// + /// This is the slow path of `get_or_create_cell`, separated for testability. + /// It handles the case where another thread may have inserted a cell between + /// our fast-path check and this insertion attempt. + fn insert_or_get_existing(map: &DashMap>>, key: &Q) -> Arc> + where + K: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + { + let cell = Arc::new(OnceCell::new()); + let weak = Arc::downgrade(&cell); + + // Use Entry enum to atomically check-and-return or insert + match map.entry(key.to_owned()) { + Occupied(mut entry) => { + // Entry exists - check if still alive + if let Some(existing) = entry.get().upgrade() { + // Another thread's cell is still alive - use it + return existing; + } + // Expired - replace with ours + entry.insert(weak); + } + Vacant(entry) => { + entry.insert(weak); + } + } + + // We inserted our cell, return it + cell + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use thread_aware::affinity::pinned_affinities; + + #[test] + fn relocated_delegates_to_inner() { + let affinities = pinned_affinities(&[2]); + let source = affinities[0].into(); + let destination = affinities[1]; + + let merger: Merger = Merger::new(); + let relocated = merger.relocated(source, destination); + + // Verify the relocated merger still works + assert!(relocated.is_empty()); + } + + #[test] + fn fast_path_returns_existing() { + let map: DashMap>> = DashMap::new(); + let existing_cell = Arc::new(OnceCell::new()); + map.insert("key".to_string(), Arc::downgrade(&existing_cell)); + + let result = Merger::::get_or_create_cell(&map, "key"); + + assert!(Arc::ptr_eq(&result, &existing_cell)); + } + + #[test] + fn replaces_expired_entry() { + let map: DashMap>> = DashMap::new(); + let expired_weak = Arc::downgrade(&Arc::new(OnceCell::::new())); + map.insert("key".to_string(), expired_weak); + + let result = Merger::::get_or_create_cell(&map, "key"); + + let entry = map.get("key").unwrap(); + assert!(Arc::ptr_eq(&result, &entry.value().upgrade().unwrap())); + } + + /// Simulates a race where another thread inserted between fast-path check and `entry()`. + #[test] + fn race_returns_existing() { + let map: DashMap>> = DashMap::new(); + let other_cell = Arc::new(OnceCell::new()); + map.insert("key".to_string(), Arc::downgrade(&other_cell)); + + let result = Merger::::insert_or_get_existing(&map, "key"); + + assert!(Arc::ptr_eq(&result, &other_cell)); + } + + #[tokio::test] + async fn cleanup_after_completion() { + let group: Merger = Merger::new(); + assert!(group.is_empty()); + + // Single call should clean up after completion + let result = group.execute("key1", || async { "Result".to_string() }).await; + assert_eq!(result, "Result"); + assert!(group.is_empty(), "Map should be empty after single call completes"); + + // Multiple concurrent calls should clean up after all complete + let futures: Vec<_> = (0..10) + .map(|_| { + group.execute("key2", || async { + tokio::time::sleep(Duration::from_millis(50)).await; + "Result".to_string() + }) + }) + .collect(); + + // While in flight, map should have an entry + assert_eq!(group.len(), 1); + + for fut in futures { + assert_eq!(fut.await, "Result"); + } + + assert!(group.is_empty(), "Map should be empty after all concurrent calls complete"); + + // Multiple different keys should all be cleaned up + let fut1 = group.execute("a", || async { "A".to_string() }); + let fut2 = group.execute("b", || async { "B".to_string() }); + let fut3 = group.execute("c", || async { "C".to_string() }); + + assert_eq!(group.len(), 3); + + let (r1, r2, r3) = tokio::join!(fut1, fut2, fut3); + assert_eq!(r1, "A"); + assert_eq!(r2, "B"); + assert_eq!(r3, "C"); + + assert!(group.is_empty(), "Map should be empty after all keys complete"); + } +} diff --git a/crates/uniflight/tests/work.rs b/crates/uniflight/tests/work.rs new file mode 100644 index 00000000..47231d20 --- /dev/null +++ b/crates/uniflight/tests/work.rs @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for [`Merger::execute()`]. + +use std::{ + sync::{ + Arc, + atomic::{ + AtomicUsize, + Ordering::{AcqRel, Acquire}, + }, + }, + time::Duration, +}; + +use futures_util::{StreamExt, stream::FuturesUnordered}; +use uniflight::Merger; + +fn unreachable_future() -> std::future::Pending { + std::future::pending() +} + +#[tokio::test] +async fn direct_call() { + let group = Merger::::new_per_process(); + let result = group + .execute("key", || async { + tokio::time::sleep(Duration::from_millis(10)).await; + "Result".to_string() + }) + .await; + assert_eq!(result, "Result"); +} + +#[tokio::test] +async fn parallel_call() { + let call_counter = AtomicUsize::default(); + + let group = Merger::::new_per_process(); + let futures = FuturesUnordered::new(); + for _ in 0..10 { + futures.push(group.execute("key", || async { + tokio::time::sleep(Duration::from_millis(100)).await; + call_counter.fetch_add(1, AcqRel); + "Result".to_string() + })); + } + + assert!(futures.all(|out| async move { out == "Result" }).await); + assert_eq!(call_counter.load(Acquire), 1); +} + +#[tokio::test] +async fn parallel_call_seq_await() { + let call_counter = AtomicUsize::default(); + + let group = Merger::::new_per_process(); + let mut futures = Vec::new(); + for _ in 0..10 { + futures.push(group.execute("key", || async { + tokio::time::sleep(Duration::from_millis(100)).await; + call_counter.fetch_add(1, AcqRel); + "Result".to_string() + })); + } + + for fut in futures { + assert_eq!(fut.await, "Result"); + } + assert_eq!(call_counter.load(Acquire), 1); +} + +#[tokio::test] +async fn call_with_static_str_key() { + let group = Merger::::new_per_process(); + let result = group + .execute("key", || async { + tokio::time::sleep(Duration::from_millis(1)).await; + "Result".to_string() + }) + .await; + assert_eq!(result, "Result"); +} + +#[tokio::test] +async fn call_with_static_string_key() { + let group = Merger::::new_per_process(); + let result = group + .execute("key", || async { + tokio::time::sleep(Duration::from_millis(1)).await; + "Result".to_string() + }) + .await; + assert_eq!(result, "Result"); +} + +#[tokio::test] +async fn call_with_custom_key() { + #[derive(Clone, PartialEq, Eq, Hash)] + struct K(i32); + let group = Merger::::new_per_process(); + let result = group + .execute(&K(1), || async { + tokio::time::sleep(Duration::from_millis(1)).await; + "Result".to_string() + }) + .await; + assert_eq!(result, "Result"); +} + +#[tokio::test] +async fn late_wait() { + let group = Merger::::new_per_process(); + let fut_early = group.execute("key", || async { + tokio::time::sleep(Duration::from_millis(20)).await; + "Result".to_string() + }); + let fut_late = group.execute("key", unreachable_future); + assert_eq!(fut_early.await, "Result"); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(fut_late.await, "Result"); +} + +#[tokio::test] +async fn cancel() { + let group = Merger::::new_per_process(); + + // The executor was cancelled; the other awaiter will create a new future and execute. + let fut_cancel = group.execute(&"key".to_string(), unreachable_future); + let _ = tokio::time::timeout(Duration::from_millis(10), fut_cancel).await; + let fut_late = group.execute("key", || async { "Result2".to_string() }); + assert_eq!(fut_late.await, "Result2"); + + // the first executer is slow but not dropped, so the result will be the first ones. + let begin = tokio::time::Instant::now(); + let fut_1 = group.execute("key", || async { + tokio::time::sleep(Duration::from_millis(2000)).await; + "Result1".to_string() + }); + let fut_2 = group.execute(&"key".to_string(), unreachable_future); + let (v1, v2) = tokio::join!(fut_1, fut_2); + assert_eq!(v1, "Result1"); + assert_eq!(v2, "Result1"); + assert!(begin.elapsed() > Duration::from_millis(1500)); +} + +#[tokio::test] +async fn leader_panic_in_spawned_task() { + let call_counter = AtomicUsize::default(); + let group: Arc> = Arc::new(Merger::new()); + + // First task will panic in a spawned task (no catch_unwind) + let group_clone = Arc::clone(&group); + let handle = tokio::spawn(async move { + group_clone + .execute("key", || async { + tokio::time::sleep(Duration::from_millis(50)).await; + panic!("leader panicked in spawned task"); + #[expect(unreachable_code, reason = "Required to satisfy return type after panic")] + "never".to_string() + }) + .await + }); + + // Give time for the spawned task to register and start + tokio::time::sleep(Duration::from_millis(10)).await; + + // Second task should become the new leader after the first panics + let group_clone = Arc::clone(&group); + let call_counter_ref = &call_counter; + let fut_follower = group_clone.execute("key", || async { + call_counter_ref.fetch_add(1, AcqRel); + "Result".to_string() + }); + + // Wait for the spawned task to panic + handle.await.unwrap_err(); + + // The follower should succeed - Rust's drop semantics ensure the mutex is released + let result = fut_follower.await; + assert_eq!(result, "Result"); + assert_eq!(call_counter.load(Acquire), 1); +} + +#[tokio::test] +async fn debug_impl() { + let group: Merger = Merger::new(); + + // Test Debug on empty group + let debug_str = format!("{group:?}"); + assert!(debug_str.contains("Merger")); + + // Create a pending work item to populate the mapping + let fut = group.execute("key", || async { + tokio::time::sleep(Duration::from_millis(100)).await; + "Result".to_string() + }); + + // Debug should still work with entries in the mapping + let debug_str = format!("{group:?}"); + assert!(debug_str.contains("Merger")); + // The inner storage is a DashMap + assert!(debug_str.contains("DashMap")); + + // Complete the work + assert_eq!(fut.await, "Result"); +} + +#[tokio::test] +async fn per_process_strategy() { + let group = Merger::::new_per_process(); + let result = group.execute("key", || async { "Result".to_string() }).await; + assert_eq!(result, "Result"); +} + +#[tokio::test] +async fn per_numa_strategy() { + let group = Merger::::new_per_numa(); + let result = group.execute("key", || async { "Result".to_string() }).await; + assert_eq!(result, "Result"); +} + +#[tokio::test] +async fn per_core_strategy() { + let group = Merger::::new_per_core(); + let result = group.execute("key", || async { "Result".to_string() }).await; + assert_eq!(result, "Result"); +} + +#[tokio::test] +async fn clone_shares_state() { + let group1 = Merger::::new_per_process(); + let group2 = group1.clone(); + + let call_counter = AtomicUsize::default(); + + // Start work on clone 1 + let fut1 = group1.execute("key", || async { + tokio::time::sleep(Duration::from_millis(50)).await; + call_counter.fetch_add(1, AcqRel); + "Result".to_string() + }); + + // Clone 2 should join the same work + let fut2 = group2.execute("key", || async { + call_counter.fetch_add(1, AcqRel); + "Unreachable".to_string() + }); + + let (r1, r2) = tokio::join!(fut1, fut2); + assert_eq!(r1, "Result"); + assert_eq!(r2, "Result"); + // Work should only execute once + assert_eq!(call_counter.load(Acquire), 1); +}