diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml index 2504c72af..ca50d4f60 100644 --- a/.github/workflows/create-tag.yml +++ b/.github/workflows/create-tag.yml @@ -33,6 +33,7 @@ on: - provider-anthropic - provider-openai - provider-xai + - pubsub - rbac-proxy - session-manager - slack diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 131f83461..17485f962 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,7 @@ on: - 'provider-anthropic/v*' - 'provider-openai/v*' - 'provider-xai/v*' + - 'pubsub/v*' - 'rbac-proxy/v*' - 'session-manager/v*' - 'slack/v*' diff --git a/pubsub/Cargo.lock b/pubsub/Cargo.lock new file mode 100644 index 000000000..aaaee183e --- /dev/null +++ b/pubsub/Cargo.lock @@ -0,0 +1,2493 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.4", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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 = "iii-helpers" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09daa7c14a9e4c1f7077c4a181918d207e3f05cdfea8b2d7781bbeb80caf4c5d" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-pubsub" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "futures", + "iii-sdk", + "redis", + "schemars", + "serde", + "serde_json", + "serde_yaml", + "serial_test", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "iii-sdk" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5957568413b9a5178c11bf91b20909e93e568b187e259e941ba6009a7ec3f5c1" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.4", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[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 = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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 = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "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.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[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 = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/pubsub/Cargo.toml b/pubsub/Cargo.toml new file mode 100644 index 000000000..ed7d16a9d --- /dev/null +++ b/pubsub/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] + +[package] +name = "iii-pubsub" +version = "0.1.0" +edition = "2021" +description = "Pub/sub worker for iii — topic-based publish/subscribe messaging (subscribe trigger type, publish function)" +license = "Apache-2.0" +repository = "https://github.com/iii-hq/workers" +publish = false + +[lib] +name = "iii_pubsub" +path = "src/lib.rs" + +[[bin]] +name = "pubsub" +path = "src/main.rs" + +[dependencies] +iii-sdk = "=0.20.0" +async-trait = "0.1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +clap = { version = "4", features = ["derive"] } +schemars = "0.8" +futures = "0.3" +redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } + +[dev-dependencies] +serial_test = "3" diff --git a/pubsub/README.md b/pubsub/README.md new file mode 100644 index 000000000..3e2f17586 --- /dev/null +++ b/pubsub/README.md @@ -0,0 +1,137 @@ +# pubsub + +Topic-based publish/subscribe messaging. Registers the `subscribe` trigger +type and the `publish` service function, replacing the engine builtin +`iii-pubsub`. Any function bound to a `subscribe` trigger on a topic receives +every event published to that topic; `publish` fans an event's `data` out to +all of a topic's subscribers as fire-and-forget invocations. + +## Install + +```bash +iii worker add pubsub +``` + +`iii worker add` fetches the binary, writes a config block into +`~/.iii/config.yaml`, and the engine starts the worker on the next +`iii start`. + +## Quickstart + +Bind a function to the `subscribe` trigger type with a `topic`, then call the +`publish` function with `{ topic, data }`: + +```rust +use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; +use iii_sdk::{InitOptions, RegisterFunction, errors::Error, register_worker}; +use serde_json::{json, Value}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let iii = register_worker("ws://localhost:49134", InitOptions::default()); + + iii.register_function( + "orders::on_created", + RegisterFunction::new_async(|data: Value| async move { + println!("order event: {data}"); + Ok::(Value::Null) + }), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: iii_pubsub::TRIGGER_TYPE.to_string(), // "subscribe" + function_id: "orders::on_created".into(), + config: json!({ "topic": "orders" }), + metadata: None, + })?; + + // Any caller (function, worker, or the stream bridge) publishes with: + iii.trigger(TriggerRequest { + function_id: "publish".into(), + payload: json!({ "topic": "orders", "data": { "id": 42 } }), + action: None, + timeout_ms: Some(5000), + }) + .await?; + + tokio::signal::ctrl_c().await?; + Ok(()) +} +``` + +The subscriber receives the **raw** `data` value (no envelope). Delivery is +fire-and-forget: `publish` returns a null result once dispatched and does not +wait for or report subscriber outcomes. Publishing to a topic with no +subscribers is a silent no-op; publishing with an empty topic fails with a +`topic_not_set` error. + +## Trigger type + +This worker always registers the `subscribe` trigger type. Bind a function to +it with: + +| Field | Required | Default | Description | +|---|---|---|---| +| `topic` | yes | — | Topic to subscribe to. A missing or empty topic registers nothing (a warning is logged). | +| `condition_function_id` | no | — | Accepted for schema parity with the builtin; **never evaluated**. | + +## The `publish` function + +Registered under the bare id **`publish`** (matching the builtin exactly, so +existing callers and the engine's stream bridge keep working). Input: + +| Field | Description | +|---|---| +| `topic` | Topic to publish to. Empty → `topic_not_set` error. | +| `data` | JSON payload delivered verbatim to each subscriber. | + +Returns a null result on success. + +## Configuration + +| Field | Default | Description | +|---|---|---| +| `adapter.name` | `local` | Backend: `local` (in-process broadcast) or `redis` (cross-instance Redis Pub/Sub). | +| `adapter.config.redis_url` | `redis://localhost:6379` | Redis connection URL (only for the `redis` adapter). | + +Configuration is owned by the `configuration` worker — edit it from the +console (**Configuration → Workers → pubsub**) or seed it once via +`--config .yaml` on first boot. The adapter **hot-swaps at runtime**: a +change rebuilds the backend, re-subscribes the live subscriptions onto it +*before* the swap (no delivery gap — a brief double-delivery overlap is +accepted for fire-and-forget pub/sub), then tears down the previous backend. +A build failure keeps the previous backend and config. + +### Requires removing the built-in `iii-pubsub` worker + +The built-in `iii-pubsub` worker also owns the `subscribe` trigger type and +the `publish` function. Two owners of the same trigger type on one engine +collide — whichever registers last wins — so this worker requires `iii-pubsub` +to be absent: omit it from the engine's `config.yaml` (a config that doesn't +list a worker won't run it). + +On boot, this worker queries the engine for connected workers and refuses to +start with a clear error if `iii-pubsub` is still active, so a stale config +fails loudly instead of silently racing the built-in worker for ownership of +`subscribe`/`publish`. + +## Parity vs builtin + +| Behavior | Builtin | This worker | +|---|---|---| +| Trigger type | `subscribe` | same | +| Trigger config | `{topic}` (+ advertised `condition_function_id`, never evaluated) | same (still never evaluated) | +| Service functions | `publish` (bare id — the `#[service]` prefix is discarded by the macro) | `publish` (exact same id) | +| `publish` input | `{topic, data}` | same | +| `publish` success result | null | same | +| `publish` empty topic | error `topic_not_set` / "Topic is not set" | same (code prefixes the message: `topic_not_set: Topic is not set`) | +| Delivery payload | raw `data`, no envelope | same | +| Delivery semantics | fire-and-forget, results ignored, no retries | same | +| No subscribers | silent no-op | same | +| Subscribe with missing/empty topic | warn + register nothing (Ok) | same | +| Backends | local (in-process), redis | same | +| Redis: subscriptions per topic per instance | one (second warns, dropped) | same | +| Local unsubscribe | drops the ENTIRE topic entry (bug: kills co-subscribers) | removes only the given id (**deliberate fix**) | +| Adapter hot-swap | gated build-first, resubscribe-before-swap | same | +| Config entry id | `iii-pubsub` | `pubsub` (new entry, seeded on first boot) | +| Config schema shape | hand-built `oneOf` union (local/redis) | plain derive — accepted values identical, plainer console form | diff --git a/pubsub/build.rs b/pubsub/build.rs new file mode 100644 index 000000000..81caa36d6 --- /dev/null +++ b/pubsub/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); +} diff --git a/pubsub/iii.worker.yaml b/pubsub/iii.worker.yaml new file mode 100644 index 000000000..ad62fbb8d --- /dev/null +++ b/pubsub/iii.worker.yaml @@ -0,0 +1,12 @@ +iii: v1 +name: pubsub +language: rust +deploy: binary +manifest: Cargo.toml +bin: pubsub +description: Topic-based publish/subscribe messaging — registers the `subscribe` trigger type and the `publish` function. +config: + adapter: + name: local +dependencies: + configuration: "^0.19.0" diff --git a/pubsub/skills/SKILL.md b/pubsub/skills/SKILL.md new file mode 100644 index 000000000..78341ea93 --- /dev/null +++ b/pubsub/skills/SKILL.md @@ -0,0 +1,63 @@ +--- +name: pubsub +tags: pubsub, publish, subscribe, events, redis +description: >- + Fire-and-forget topic pub/sub: broadcast an event with `publish` and every + matching `subscribe` trigger receives it. Use for real-time notifications + where missed events are acceptable. +--- + +# pubsub + +The `pubsub` worker is topic-based publish/subscribe messaging. Publish an event to a named topic with the `publish` function and every registered `subscribe` trigger whose `topic` matches is invoked with the raw payload — no envelope, no persistence, no retries. It is fire-and-forget broadcast: subscribers receive each event as it arrives, and a subscriber that is offline simply misses it. + +Install it with `iii worker add pubsub`. It replaces the former built-in `iii-pubsub` worker: the trigger type (`subscribe`) and the function id (`publish`) are unchanged, so existing application code keeps working — only the owner moved from an in-process builtin to this standalone worker. + +The worker exposes one callable function (`publish`, registered with the bare function id `"publish"` — no namespace prefix) and one trigger type (`subscribe`). Two adapters: `local` (default; in-memory broadcast; only delivers to subscribers in this engine process; no external dependency) and `redis` (`redis_url: ${REDIS_URL:redis://localhost:6379}`; uses Redis Pub/Sub so events propagate across multiple engine instances). The adapter is set in the `pubsub` configuration entry and hot-swaps at runtime. + +## When to Use + +- Real-time notifications consumers may miss without consequence — UI live updates, ephemeral signals, telemetry mirroring. +- Loose coupling where an event source should not know about its consumers. +- Cross-instance fan-out of ephemeral events (configure the `redis` adapter). + +## Boundaries + +- No persistence, ordering, or retries — a subscriber that is down misses events permanently. Use the `queue` worker (topic mode, `durable:subscriber`) when every consumer must process every event. +- Topic matching is exact string equality; there are no wildcards or hierarchical patterns. Register one trigger per topic. +- An empty topic is rejected: `publish` returns `topic_not_set`, and a `subscribe` trigger registered with an empty topic never fires. +- For key/value or stream change reactivity, use the `state` or `stream` worker instead — `pubsub` carries discrete events, not state. + +## Functions + +- `publish` — broadcast an event to a topic; every `subscribe` trigger on that exact topic receives the raw `data`. Empty topic returns `topic_not_set`. + +## Reactive triggers + +Bind a `subscribe` trigger when a function should run every time `publish` broadcasts to a configured topic. The handler receives the raw `data` value from the publish call directly — no envelope. Multiple triggers can subscribe to the same topic; each gets an independent copy (true fan-out). + +Reach for it when: + +- A domain event from one part of the system should kick off side effects elsewhere without the publisher knowing the consumers. +- You want cross-instance fan-out via the `redis` adapter. + +If subscribers must reliably process every event with retries and dead-letter handling, use the `queue` worker's `durable:subscriber` instead — `subscribe` is fire-and-forget. + +### How to bind + +1. Register a handler: `iii.registerFunction('notifications::on-order-shipped', handler)`. +2. Register the trigger: + +```typescript +iii.registerTrigger({ + type: 'subscribe', + function_id: 'notifications::on-order-shipped', + config: { + topic: 'orders.shipped', // required, non-empty. Exact match only — no wildcards. + }, +}) +``` + +The handler receives the published `data` unchanged, with no `topic` field; if one handler serves multiple topics, embed the topic inside `data` at publish time. The handler's return value is ignored and there is no retry. + +For the published payload shape, call `iii get function info` on `publish` or the handler function id. diff --git a/pubsub/src/adapters/local.rs b/pubsub/src/adapters/local.rs new file mode 100644 index 000000000..e8c897e32 --- /dev/null +++ b/pubsub/src/adapters/local.rs @@ -0,0 +1,163 @@ +//! In-process backend (default). Port of the builtin local adapter +//! (engine/src/workers/pubsub/adapters/local_adapter.rs): a topic -> +//! {subscription id -> function id} map; publish spawns one fire-and-forget +//! invocation per subscriber with the raw data value. One deliberate fix vs +//! the builtin: unsubscribe removes only the given id instead of dropping the +//! whole topic entry (builtin bug, local_adapter.rs:82-96). + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; +use tokio::sync::RwLock; + +use super::{Invoker, PubSubAdapter}; + +type TopicName = String; +type SubscriptionId = String; +type FunctionPath = String; + +pub struct LocalAdapter { + subscriptions: Arc>>>, + invoker: Arc, +} + +impl LocalAdapter { + pub fn new(invoker: Arc) -> Self { + Self { + subscriptions: Arc::new(RwLock::new(HashMap::new())), + invoker, + } + } +} + +#[async_trait] +impl PubSubAdapter for LocalAdapter { + async fn publish(&self, topic: &str, data: Value) { + let subs = self.subscriptions.read().await; + let Some(sub_info) = subs.get(topic) else { + tracing::debug!(topic = %topic, "Event: No subscriptions found"); + return; + }; + for (_id, function_id) in sub_info.iter() { + tracing::debug!(function_id = %function_id, topic = %topic, "Event: Invoking function"); + let function_id = function_id.clone(); + let data = data.clone(); + let invoker = self.invoker.clone(); + // Fire-and-forget, parity with the builtin's spawned engine.call. + tokio::spawn(async move { + if let Err(e) = invoker.call(&function_id, data).await { + tracing::debug!(function_id = %function_id, error = %e, "pubsub delivery failed"); + } + }); + } + } + + async fn subscribe(&self, topic: &str, id: &str, function_id: &str) { + let mut subs = self.subscriptions.write().await; + subs.entry(topic.to_string()) + .or_default() + .insert(id.to_string(), function_id.to_string()); + } + + async fn unsubscribe(&self, topic: &str, id: &str) { + tracing::debug!(topic = %topic, id = %id, "Unsubscribing from PubSub topic"); + let mut subs = self.subscriptions.write().await; + if let Some(sub_info) = subs.get_mut(topic) { + sub_info.remove(id); + if sub_info.is_empty() { + subs.remove(topic); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::sync::Mutex as TokioMutex; + + #[derive(Default)] + struct RecordingInvoker { + calls: AtomicUsize, + received: TokioMutex>, + } + + #[async_trait] + impl Invoker for RecordingInvoker { + async fn call( + &self, + function_id: &str, + payload: serde_json::Value, + ) -> Result, String> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.received + .lock() + .await + .push((function_id.to_string(), payload)); + Ok(None) + } + } + + async fn wait_for_calls(inv: &RecordingInvoker, n: usize) { + for _ in 0..50 { + if inv.calls.load(Ordering::SeqCst) >= n { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + panic!("invoker never reached {n} call(s)"); + } + + #[tokio::test] + async fn publish_fans_out_raw_data_to_all_topic_subscribers() { + let inv = Arc::new(RecordingInvoker::default()); + let a = LocalAdapter::new(inv.clone()); + a.subscribe("orders", "sub-1", "fn::a").await; + a.subscribe("orders", "sub-2", "fn::b").await; + a.subscribe("other", "sub-3", "fn::c").await; + + a.publish("orders", serde_json::json!({"id": 7})).await; + wait_for_calls(&inv, 2).await; + + let received = inv.received.lock().await; + assert_eq!(received.len(), 2, "only the topic's subscribers fire"); + for (_fn_id, payload) in received.iter() { + // Parity: the subscriber gets the RAW published data, no envelope. + assert_eq!(payload, &serde_json::json!({"id": 7})); + } + let mut fns: Vec<&str> = received.iter().map(|(f, _)| f.as_str()).collect(); + fns.sort(); + assert_eq!(fns, vec!["fn::a", "fn::b"]); + } + + #[tokio::test] + async fn publish_without_subscribers_is_noop() { + let inv = Arc::new(RecordingInvoker::default()); + let a = LocalAdapter::new(inv.clone()); + a.publish("ghost", serde_json::json!(1)).await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert_eq!(inv.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn unsubscribe_removes_only_the_given_id() { + // Deviation from the builtin (which drops the whole topic entry): + // the surviving subscriber keeps receiving. + let inv = Arc::new(RecordingInvoker::default()); + let a = LocalAdapter::new(inv.clone()); + a.subscribe("orders", "sub-1", "fn::a").await; + a.subscribe("orders", "sub-2", "fn::b").await; + a.unsubscribe("orders", "sub-1").await; + + a.publish("orders", serde_json::json!({"id": 1})).await; + wait_for_calls(&inv, 1).await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let received = inv.received.lock().await; + assert_eq!(received.len(), 1); + assert_eq!(received[0].0, "fn::b"); + } +} diff --git a/pubsub/src/adapters/mod.rs b/pubsub/src/adapters/mod.rs new file mode 100644 index 000000000..41b05e22a --- /dev/null +++ b/pubsub/src/adapters/mod.rs @@ -0,0 +1,98 @@ +//! Pub/sub backends. Signature parity with the builtin trait +//! (engine/src/workers/pubsub/mod.rs:22-26); the engine handle becomes an +//! [`Invoker`] so adapters stay unit-testable without a live engine. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::config::PubSubConfig; + +pub mod local; +pub mod redis; + +pub use local::LocalAdapter; +pub use redis::RedisAdapter; + +const DEFAULT_REDIS_URL: &str = "redis://localhost:6379"; + +/// Abstraction over `iii.trigger` (the builtin used `engine.call`). Fan-out +/// deliveries go through this; results are ignored by callers (fire-and-forget +/// parity with the builtin's `tokio::spawn(engine.call(..))`). +#[async_trait] +pub trait Invoker: Send + Sync + 'static { + async fn call(&self, function_id: &str, payload: Value) -> Result, String>; +} + +/// Exact method parity with the builtin `PubSubAdapter`. +#[async_trait] +pub trait PubSubAdapter: Send + Sync + 'static { + async fn publish(&self, topic: &str, data: Value); + async fn subscribe(&self, topic: &str, id: &str, function_id: &str); + async fn unsubscribe(&self, topic: &str, id: &str); +} + +/// Build the backend named in the config. Unknown names error (parity with +/// the builtin's adapter registry: "PubSub adapter factory '' not found"). +pub async fn build_adapter( + config: &PubSubConfig, + invoker: Arc, +) -> anyhow::Result> { + match config.effective_adapter_name() { + "local" => Ok(Arc::new(LocalAdapter::new(invoker))), + "redis" => { + let url = config + .adapter + .as_ref() + .and_then(|a| a.config.as_ref()) + .and_then(|c| c.get("redis_url")) + .and_then(|v| v.as_str()) + .unwrap_or(DEFAULT_REDIS_URL) + .to_string(); + Ok(Arc::new(RedisAdapter::connect(&url, invoker).await?)) + } + other => { + anyhow::bail!( + "PubSub adapter factory '{other}' not found (expected 'local' or 'redis')" + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PubSubConfig; + + struct NoopInvoker; + + #[async_trait] + impl Invoker for NoopInvoker { + async fn call( + &self, + _function_id: &str, + _payload: serde_json::Value, + ) -> Result, String> { + Ok(None) + } + } + + #[tokio::test] + async fn build_adapter_defaults_to_local() { + let cfg = PubSubConfig::default(); + let adapter = build_adapter(&cfg, std::sync::Arc::new(NoopInvoker)).await; + assert!(adapter.is_ok()); + } + + #[tokio::test] + async fn build_adapter_rejects_unknown_name() { + let cfg: PubSubConfig = serde_yaml::from_str("{adapter: {name: kafka}}").unwrap(); + // `Arc` isn't Debug, so avoid `unwrap_err`. + let err = match build_adapter(&cfg, std::sync::Arc::new(NoopInvoker)).await { + Ok(_) => panic!("unknown adapter name must error"), + Err(e) => e, + }; + assert!(err.to_string().contains("kafka")); + } +} diff --git a/pubsub/src/adapters/redis.rs b/pubsub/src/adapters/redis.rs new file mode 100644 index 000000000..26fc60564 --- /dev/null +++ b/pubsub/src/adapters/redis.rs @@ -0,0 +1,149 @@ +//! Cross-instance Redis Pub/Sub backend. Port of the builtin +//! (engine/src/workers/pubsub/adapters/redis_adapter.rs): publish serializes +//! the data to a JSON string and PUBLISHes it; subscribe spawns one listener +//! task per topic. Parity quirks kept: only ONE subscription per topic per +//! instance (a second subscribe warns and is dropped); unsubscribe is +//! id-checked and aborts the topic's listener task. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use redis::aio::ConnectionManager; +use redis::{AsyncCommands, Client}; +use serde_json::Value; +use tokio::sync::{Mutex, RwLock}; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use super::{Invoker, PubSubAdapter}; + +/// Builtin parity: engine/src/workers/redis.rs:9. +const REDIS_CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct RedisAdapter { + publisher: Arc>, + subscriber: Arc, + subscriptions: Arc>>, + invoker: Arc, +} + +struct SubscriptionInfo { + id: String, + task_handle: JoinHandle<()>, +} + +impl RedisAdapter { + pub async fn connect(redis_url: &str, invoker: Arc) -> anyhow::Result { + let client = Client::open(redis_url)?; + let manager = timeout(REDIS_CONNECTION_TIMEOUT, client.get_connection_manager()) + .await + .map_err(|_| { + anyhow::anyhow!( + "Redis connection timed out after {REDIS_CONNECTION_TIMEOUT:?}. \ + Please ensure Redis is running at: {redis_url}" + ) + })? + .map_err(|e| anyhow::anyhow!("Failed to connect to Redis at {redis_url}: {e}"))?; + + Ok(Self { + publisher: Arc::new(Mutex::new(manager)), + subscriber: Arc::new(client), + subscriptions: Arc::new(RwLock::new(HashMap::new())), + invoker, + }) + } +} + +#[async_trait] +impl PubSubAdapter for RedisAdapter { + async fn publish(&self, topic: &str, data: Value) { + let event_json = match serde_json::to_string(&data) { + Ok(json) => json, + Err(e) => { + tracing::error!(error = %e, topic = %topic, "Failed to serialize event data"); + return; + } + }; + let mut conn = self.publisher.lock().await; + if let Err(e) = conn.publish::<_, _, ()>(topic, &event_json).await { + tracing::error!(error = %e, topic = %topic, "Failed to publish event to Redis"); + } + } + + async fn subscribe(&self, topic: &str, id: &str, function_id: &str) { + // Builtin quirk kept: one listener per topic per instance. + if self.subscriptions.read().await.contains_key(topic) { + tracing::warn!(topic = %topic, id = %id, "Already subscribed to topic"); + return; + } + + let topic_owned = topic.to_string(); + let function_id = function_id.to_string(); + let subscriber = self.subscriber.clone(); + let invoker = self.invoker.clone(); + + let task_handle = tokio::spawn(async move { + let mut pubsub = match subscriber.get_async_pubsub().await { + Ok(pubsub) => pubsub, + Err(e) => { + tracing::error!(error = %e, topic = %topic_owned, "Failed to get async pubsub connection"); + return; + } + }; + if let Err(e) = pubsub.subscribe(&topic_owned).await { + tracing::error!(error = %e, topic = %topic_owned, "Failed to subscribe to Redis channel"); + return; + } + let mut messages = pubsub.into_on_message(); + while let Some(msg) = messages.next().await { + let payload: String = match msg.get_payload() { + Ok(payload) => payload, + Err(e) => { + tracing::error!(error = %e, topic = %topic_owned, "Failed to get message payload"); + continue; + } + }; + let data: Value = match serde_json::from_str(&payload) { + Ok(data) => data, + Err(e) => { + tracing::error!(error = %e, topic = %topic_owned, "Failed to parse message as JSON"); + continue; + } + }; + let invoker = invoker.clone(); + let function_id = function_id.clone(); + tokio::spawn(async move { + if let Err(e) = invoker.call(&function_id, data).await { + tracing::debug!(function_id = %function_id, error = %e, "pubsub delivery failed"); + } + }); + } + tracing::debug!(topic = %topic_owned, "Subscription task ended"); + }); + + self.subscriptions.write().await.insert( + topic.to_string(), + SubscriptionInfo { + id: id.to_string(), + task_handle, + }, + ); + } + + async fn unsubscribe(&self, topic: &str, id: &str) { + let mut subs = self.subscriptions.write().await; + if let Some(sub_info) = subs.remove(topic) { + if sub_info.id == id { + sub_info.task_handle.abort(); + } else { + tracing::warn!(topic = %topic, id = %id, "Subscription ID mismatch, not unsubscribing"); + subs.insert(topic.to_string(), sub_info); + } + } else { + tracing::warn!(topic = %topic, id = %id, "No active subscription found for topic"); + } + } +} diff --git a/pubsub/src/boot.rs b/pubsub/src/boot.rs new file mode 100644 index 000000000..754aaee0f --- /dev/null +++ b/pubsub/src/boot.rs @@ -0,0 +1,180 @@ +//! Boot sequence: builtin guard → build adapter → hub → register the +//! `subscribe` trigger type and the `publish` service function. + +use std::sync::Arc; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::{IIIClient, RegisterFunction, RegisterTriggerType}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::adapters::{self, Invoker}; +use crate::config::PubSubConfig; +use crate::hub::Hub; +use crate::trigger::{SubscribeTriggerHandler, SubscribeTriggerSpec}; +use crate::{PUBLISH_FUNCTION_ID, TRIGGER_TYPE}; + +const LIST_WORKERS_FUNCTION_ID: &str = "engine::workers::list"; +const BUILTIN_III_PUBSUB_WORKER_ID: &str = "iii-pubsub"; + +pub type ApplyLock = Arc>; +pub type ConfigCell = Arc>; + +/// Input of the `publish` function — exact field parity with the builtin +/// (engine/src/workers/pubsub/pubsub.rs:78-84). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PubSubInput { + /// Topic to publish to. Subscribers registered for this topic receive the event. + pub topic: String, + /// JSON payload delivered to each subscriber. + pub data: Value, +} + +pub struct BootHandle { + pub hub: Arc, + pub invoker: Arc, + pub config: ConfigCell, + pub apply_lock: ApplyLock, +} + +impl BootHandle { + pub async fn shutdown(&self) { + self.hub.shutdown().await; + } +} + +/// SDK-backed Invoker: engine.call parity via iii.trigger. Callers (the +/// adapters' fan-out) spawn and ignore the result, matching the builtin's +/// fire-and-forget `tokio::spawn(engine.call(..))`. +struct SdkInvoker { + iii: Arc, +} + +#[async_trait::async_trait] +impl Invoker for SdkInvoker { + async fn call(&self, function_id: &str, payload: Value) -> Result, String> { + self.iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload, + action: None, + timeout_ms: None, + }) + .await + .map(Some) + .map_err(|e| e.to_string()) + } +} + +pub async fn start(iii: Arc, config: PubSubConfig) -> anyhow::Result { + guard_against_builtin_pubsub(&iii).await?; + + let invoker: Arc = Arc::new(SdkInvoker { iii: iii.clone() }); + let adapter = adapters::build_adapter(&config, invoker.clone()).await?; + let hub = Arc::new(Hub::new(adapter)); + + // The engine delivers every `subscribe` trigger binding through this + // handler into the hub. + let handler = SubscribeTriggerHandler::new(hub.clone()); + let _ = iii.register_trigger_type( + RegisterTriggerType::new(TRIGGER_TYPE, "Subscribe to a topic", handler) + .trigger_request_format::(), + ); + + // Service function: the bare id `publish` (see PUBLISH_FUNCTION_ID docs — + // exact path parity with the builtin is load-bearing). + let hub_for_publish = hub.clone(); + iii.register_function( + PUBLISH_FUNCTION_ID, + RegisterFunction::new_async(move |input: PubSubInput| { + let hub = hub_for_publish.clone(); + async move { + hub.publish(&input.topic, input.data) + .await + .map_err(Error::Handler)?; + // Builtin returns Success(None) — a null result. + Ok::<_, Error>(Value::Null) + } + }) + .description("Publishes an event"), + ); + + Ok(BootHandle { + hub, + invoker, + config: Arc::new(tokio::sync::RwLock::new(config.normalized())), + apply_lock: Arc::new(tokio::sync::Mutex::new(())), + }) +} + +async fn guard_against_builtin_pubsub(iii: &Arc) -> anyhow::Result<()> { + let workers_list = iii + .trigger(TriggerRequest { + function_id: LIST_WORKERS_FUNCTION_ID.to_string(), + payload: serde_json::json!({}), + action: None, + timeout_ms: Some(5000), + }) + .await + .map_err(|e| anyhow::anyhow!("failed to query {LIST_WORKERS_FUNCTION_ID}: {e}"))?; + + if builtin_iii_pubsub_active(&workers_list) { + anyhow::bail!( + "cannot start the pubsub worker: the built-in iii-pubsub worker is active and owns \ + the 'subscribe' trigger type and the 'publish' function. Remove iii-pubsub from the \ + engine config (a config.yaml that doesn't list it won't run it), then start this \ + worker." + ); + } + Ok(()) +} + +fn builtin_iii_pubsub_active(workers_list: &serde_json::Value) -> bool { + workers_list + .get("workers") + .and_then(|w| w.as_array()) + .is_some_and(|workers| { + workers.iter().any(|worker| { + worker.get("id").and_then(|v| v.as_str()) == Some(BUILTIN_III_PUBSUB_WORKER_ID) + || worker.get("name").and_then(|v| v.as_str()) + == Some(BUILTIN_III_PUBSUB_WORKER_ID) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_builtin_by_id() { + let v = serde_json::json!({"workers": [{"id": "iii-pubsub"}]}); + assert!(builtin_iii_pubsub_active(&v)); + } + + #[test] + fn detects_builtin_by_name() { + let v = serde_json::json!({"workers": [{"id": "x", "name": "iii-pubsub"}]}); + assert!(builtin_iii_pubsub_active(&v)); + } + + #[test] + fn absent_builtin_passes() { + let v = serde_json::json!({"workers": [{"id": "iii-http"}]}); + assert!(!builtin_iii_pubsub_active(&v)); + } + + #[test] + fn empty_list_passes() { + assert!(!builtin_iii_pubsub_active( + &serde_json::json!({"workers": []}) + )); + } + + #[test] + fn missing_key_passes() { + assert!(!builtin_iii_pubsub_active(&serde_json::json!({}))); + } +} diff --git a/pubsub/src/config.rs b/pubsub/src/config.rs new file mode 100644 index 000000000..67115b6bc --- /dev/null +++ b/pubsub/src/config.rs @@ -0,0 +1,114 @@ +//! Worker configuration: which pub/sub backend the hub uses. +//! Mirrors the builtin's PubSubModuleConfig +//! (engine/src/workers/pubsub/config.rs) — one `adapter` field, closed set +//! `local` (default) / `redis`. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const DEFAULT_ADAPTER: &str = "local"; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AdapterEntry { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PubSubConfig { + /// Pub/sub backend selection and its adapter-specific config. `local` + /// (default, in-process broadcast) or `redis` (cross-instance Redis + /// Pub/Sub, config: `{redis_url}`). Hot-swap tier: a runtime edit rebuilds + /// the backend, re-subscribes the live subscriptions onto it, and tears + /// down the previous one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, +} + +impl PubSubConfig { + pub fn effective_adapter_name(&self) -> &str { + self.adapter + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(DEFAULT_ADAPTER) + } + + /// The `(name, config)` pair an adapter would be built from, normalizing + /// an absent adapter to the default — parity with the builtin's + /// `effective_adapter` (pubsub.rs:443-449) so `None` vs `Some(local)` is + /// not a false change in the config hot-swap. + pub fn effective_adapter(&self) -> (String, Option) { + ( + self.effective_adapter_name().to_string(), + self.adapter.as_ref().and_then(|a| a.config.clone()), + ) + } + + pub fn normalized(&self) -> Self { + self.clone() + } + + pub fn json_schema() -> Value { + serde_json::to_value(schemars::schema_for!(PubSubConfig)).unwrap_or(Value::Null) + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } + + pub fn from_json(value: &Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|e| format!("invalid pubsub configuration: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_uses_local_adapter() { + let c = PubSubConfig::default(); + assert_eq!(c.effective_adapter_name(), "local"); + } + + #[test] + fn deserializes_adapter_entry() { + let c: PubSubConfig = + serde_yaml::from_str("{adapter: {name: redis, config: {redis_url: 'redis://x:6379'}}}") + .unwrap(); + assert_eq!(c.effective_adapter_name(), "redis"); + assert_eq!( + c.adapter.unwrap().config.unwrap()["redis_url"], + "redis://x:6379" + ); + } + + #[test] + fn rejects_unknown_fields() { + let r: Result = serde_yaml::from_str("{adapter: {name: local}, wat: 1}"); + assert!(r.is_err(), "deny_unknown_fields must reject 'wat'"); + } + + #[test] + fn effective_adapter_treats_none_as_default() { + // Parity with the builtin's effective_adapter (pubsub.rs:443-449): + // absent adapter and explicit `local` are NOT a change. + let none = PubSubConfig::default(); + let local: PubSubConfig = serde_yaml::from_str("{adapter: {name: local}}").unwrap(); + let redis: PubSubConfig = serde_yaml::from_str("{adapter: {name: redis}}").unwrap(); + assert_eq!(none.effective_adapter(), local.effective_adapter()); + assert_ne!(none.effective_adapter(), redis.effective_adapter()); + } + + #[test] + fn json_roundtrip() { + let c: PubSubConfig = serde_yaml::from_str("{adapter: {name: redis}}").unwrap(); + let back = PubSubConfig::from_json(&c.to_json()).unwrap(); + assert_eq!(back.effective_adapter_name(), "redis"); + } +} diff --git a/pubsub/src/configuration.rs b/pubsub/src/configuration.rs new file mode 100644 index 000000000..605dae2f0 --- /dev/null +++ b/pubsub/src/configuration.rs @@ -0,0 +1,256 @@ +//! Integration with the builtin `configuration` worker. +//! +//! The worker registers its config schema under the id `pubsub` (what the +//! console renders as an editable form), reads the authoritative value before +//! binding, and hot-applies `configuration:updated` events by rebuilding the +//! backend adapter and rebinding the live subscriptions onto it. +//! +//! [`on_config_change`] is serialized end-to-end by an [`ApplyLock`]: the SDK +//! dispatches each function invocation via `tokio::spawn`, so overlapping +//! `configuration:updated` events could otherwise interleave their fetch → +//! decide → swap sequences. Mirrors the builtin's subscriptions/apply lock +//! (engine/src/workers/pubsub/pubsub.rs:508-513). + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; +use iii_sdk::{IIIClient, RegisterFunction}; +use serde_json::{json, Value}; + +use crate::adapters::{self, Invoker}; +use crate::boot::{ApplyLock, ConfigCell}; +use crate::config::PubSubConfig; +use crate::hub::Hub; + +pub const CONFIG_ID: &str = "pubsub"; +const CONFIG_FN_ID: &str = "pubsub::on-config-change"; +const CONFIG_RETRIES: u32 = 3; +const CONFIG_RETRY_BACKOFF_MS: u64 = 250; +/// Upper bound on every `configuration::*` bus call (mirrors the builtin's +/// `CONFIG_BUS_TIMEOUT` of 10s, configuration.rs:39): a hung provider must not +/// wedge boot or reload. +const CONFIG_BUS_TIMEOUT_MS: u64 = 10_000; + +/// Register the `pubsub` configuration entry: schema + metadata refresh on +/// every boot; `initial_value` (the `--config` seed, or built-in defaults) is +/// included only when nothing is stored yet, so runtime edits survive restarts. +pub async fn register_config(iii: &IIIClient, seed: Option<&PubSubConfig>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "PubSub", + "description": "PubSub worker settings — the pub/sub backend adapter (`local` in-process broadcast, or `redis` for cross-instance delivery). The adapter hot-swaps at runtime: a change rebuilds the backend and re-subscribes live subscriptions onto it.", + "schema": PubSubConfig::json_schema(), + }); + if should_seed_initial_value(iii).await? { + let seed = seed.cloned().unwrap_or_default().normalized(); + payload["initial_value"] = seed.to_json(); + } + trigger_with_retry( + iii, + "configuration::register", + payload, + CONFIG_BUS_TIMEOUT_MS, + ) + .await?; + Ok(()) +} + +/// Read the live configuration value. A missing/null value falls back to the +/// built-in default; a malformed stored value is an error so callers keep their +/// previous config. +pub async fn fetch_config(iii: &IIIClient) -> Result { + match try_get_config_value(iii).await? { + Some(value) if !value.is_null() => PubSubConfig::from_json(&value), + _ => { + tracing::info!("no `{CONFIG_ID}` configuration value stored; using built-in default"); + Ok(PubSubConfig::default()) + } + } +} + +async fn should_seed_initial_value(iii: &IIIClient) -> Result { + match try_get_config_value(iii).await? { + Some(value) if !value.is_null() => Ok(false), + _ => Ok(true), + } +} + +async fn try_get_config_value(iii: &IIIClient) -> Result, String> { + match trigger_with_retry( + iii, + "configuration::get", + json!({ "id": CONFIG_ID }), + CONFIG_BUS_TIMEOUT_MS, + ) + .await + { + Ok(resp) => Ok(resp.get("value").cloned()), + Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +/// Builtin parity (pubsub.rs:545): the effective `(name, config)` pair is the +/// change signal — same pair, no rebuild. +fn swap_needed(current: &PubSubConfig, next: &PubSubConfig) -> bool { + current.effective_adapter() != next.effective_adapter() +} + +/// Register `pubsub::on-config-change` and subscribe it to +/// `configuration:updated` events for the `pubsub` entry. The handler ignores +/// the trigger payload and re-fetches the authoritative value (the bus function +/// is discoverable, so a caller-supplied payload must not be trusted). +pub fn register_config_trigger( + iii: &Arc, + hub: Arc, + invoker: Arc, + cell: ConfigCell, + apply_lock: ApplyLock, +) -> Result<(), Error> { + let hub_for_fn = hub.clone(); + let invoker_for_fn = invoker.clone(); + let cell_for_fn = cell.clone(); + let apply_lock_for_fn = apply_lock.clone(); + let engine = iii.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_payload: ConfigChangeRequest| { + let hub = hub_for_fn.clone(); + let invoker = invoker_for_fn.clone(); + let cell = cell_for_fn.clone(); + let apply_lock = apply_lock_for_fn.clone(); + let engine = engine.clone(); + async move { + on_config_change(&engine, &hub, &invoker, &cell, &apply_lock).await; + Ok::<_, Error>(ConfigChangeAck { ok: true }) + } + }) + .description( + "Internal: reload pubsub configuration from the authoritative store on change.", + ), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: CONFIG_FN_ID.to_string(), + config: json!({ + "configuration_id": CONFIG_ID, + "event_types": ["configuration:updated"], + }), + metadata: None, + })?; + Ok(()) +} + +/// Re-fetch the authoritative config and, if the effective adapter changed, +/// build a new backend and hot-swap it (gated: a build failure keeps both the +/// old backend AND old config — builtin parity, pubsub.rs:551-552). The +/// [`ApplyLock`] serializes overlapping runs (see module docs). +async fn on_config_change( + iii: &IIIClient, + hub: &Arc, + invoker: &Arc, + cell: &ConfigCell, + apply_lock: &ApplyLock, +) { + let _guard = apply_lock.lock().await; + + // Never trust the trigger payload; re-fetch the authoritative value + // (builtin parity, configuration.rs:122-127). + let next = match fetch_config(iii).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::error!(error = %e, "pubsub config-change: fetch failed; keeping previous config"); + return; + } + }; + + let current = cell.read().await.clone(); + if !swap_needed(¤t, &next) { + *cell.write().await = next; + tracing::debug!("pubsub configuration reloaded (adapter unchanged)"); + return; + } + + let new_adapter = match adapters::build_adapter(&next, invoker.clone()).await { + Ok(a) => a, + Err(e) => { + tracing::error!(error = %e, "pubsub: failed to build new adapter; keeping previous backend and config"); + return; + } + }; + + hub.swap_adapter(new_adapter).await; + *cell.write().await = next; + tracing::info!("pubsub configuration reloaded (adapter hot-swapped)"); +} + +async fn trigger_with_retry( + iii: &IIIClient, + function_id: &str, + payload: Value, + timeout_ms: u64, +) -> Result { + let mut last_err = String::new(); + for attempt in 1..=CONFIG_RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(timeout_ms), + }) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e.to_string(); + if attempt < CONFIG_RETRIES { + tokio::time::sleep(Duration::from_millis( + CONFIG_RETRY_BACKOFF_MS * u64::from(attempt), + )) + .await; + } + } + } + } + Err(format!( + "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" + )) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct ConfigChangeAck { + pub ok: bool, +} + +#[derive(Debug, Default, Clone, serde::Deserialize, schemars::JsonSchema)] +pub struct ConfigChangeRequest {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn swap_needed_only_when_effective_adapter_changes() { + let none = PubSubConfig::default(); + let local: PubSubConfig = serde_yaml::from_str("{adapter: {name: local}}").unwrap(); + let redis: PubSubConfig = serde_yaml::from_str("{adapter: {name: redis}}").unwrap(); + let redis_url: PubSubConfig = + serde_yaml::from_str("{adapter: {name: redis, config: {redis_url: 'redis://y'}}}") + .unwrap(); + + assert!(!swap_needed(&local, &local)); + assert!( + !swap_needed(&none, &local), + "None normalizes to local (builtin parity)" + ); + assert!(swap_needed(&local, &redis)); + assert!( + swap_needed(&redis, &redis_url), + "adapter config change forces a rebuild" + ); + } +} diff --git a/pubsub/src/hub.rs b/pubsub/src/hub.rs new file mode 100644 index 000000000..3b62f0d26 --- /dev/null +++ b/pubsub/src/hub.rs @@ -0,0 +1,246 @@ +//! Subscription tracking + adapter hot-swap core. Port of the builtin +//! PubSubWorker's subscriptions set and apply_config swap +//! (engine/src/workers/pubsub/pubsub.rs:64-76, 508-577). The subscriptions +//! Mutex is also the swap lock: subscribe/unsubscribe/swap serialize on it so +//! a subscription can never land on a backend being torn down. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::Value; +use tokio::sync::{Mutex, RwLock}; + +use crate::adapters::PubSubAdapter; + +/// `trigger id -> (topic, function_id)`. The authoritative set the adapter +/// hot-swap rebinds, and the topic lookup for id-only unregisters. +type SubscriptionSet = HashMap; + +pub struct Hub { + adapter: RwLock>, + subscriptions: Mutex, +} + +impl Hub { + pub fn new(adapter: Arc) -> Self { + Self { + adapter: RwLock::new(adapter), + subscriptions: Mutex::new(SubscriptionSet::new()), + } + } + + async fn adapter_snapshot(&self) -> Arc { + self.adapter.read().await.clone() + } + + /// Publish `data` to `topic`. Parity with the builtin `publish` function + /// (pubsub.rs:89-107): empty topic fails with `topic_not_set`; delivery is + /// delegated to the backend (fire-and-forget) and always succeeds. + pub async fn publish(&self, topic: &str, data: Value) -> Result<(), String> { + if topic.is_empty() { + return Err("topic_not_set: Topic is not set".to_string()); + } + tracing::debug!(topic = %topic, "Publishing event"); + self.adapter_snapshot().await.publish(topic, data).await; + Ok(()) + } + + /// Record and bind a subscription. Empty topic: warn + no-op (parity with + /// the builtin register_trigger, pubsub.rs:134-139). + pub async fn subscribe(&self, id: &str, topic: &str, function_id: &str) { + if topic.is_empty() { + tracing::warn!(function_id = %function_id, "Topic is not set for trigger"); + return; + } + let mut subscriptions = self.subscriptions.lock().await; + self.adapter_snapshot() + .await + .subscribe(topic, id, function_id) + .await; + subscriptions.insert(id.to_string(), (topic.to_string(), function_id.to_string())); + } + + /// Unbind by trigger id alone (the SDK's unregister stub carries only the + /// id); the topic comes from the tracked set. Unknown id: no-op. + pub async fn unsubscribe(&self, id: &str) { + let mut subscriptions = self.subscriptions.lock().await; + if let Some((topic, _function_id)) = subscriptions.remove(id) { + self.adapter_snapshot().await.unsubscribe(&topic, id).await; + } + } + + pub async fn subscription_count(&self) -> usize { + self.subscriptions.lock().await.len() + } + + /// Hot-swap the backend, builtin order (pubsub.rs:552-570): re-subscribe + /// the tracked set onto the new backend BEFORE the swap (no delivery gap; + /// the brief double-subscription overlap is accepted for fire-and-forget + /// pub/sub), swap so new publishes route through the new backend, then + /// tear the set off the previous backend (aborting redis listener tasks). + pub async fn swap_adapter(&self, new_adapter: Arc) { + let subscriptions = self.subscriptions.lock().await; + for (id, (topic, function_id)) in subscriptions.iter() { + new_adapter.subscribe(topic, id, function_id).await; + } + let previous = { + let mut adapter = self.adapter.write().await; + std::mem::replace(&mut *adapter, new_adapter) + }; + for (id, (topic, _function_id)) in subscriptions.iter() { + previous.unsubscribe(topic, id).await; + } + tracing::info!( + subscriptions = subscriptions.len(), + "pubsub: adapter hot-swapped; subscriptions rebound onto the new backend" + ); + } + + /// Tear down every tracked subscription (parity with the builtin destroy, + /// pubsub.rs:294-330: redis listener tasks must be aborted, not leaked). + pub async fn shutdown(&self) { + let mut subscriptions = self.subscriptions.lock().await; + let adapter = self.adapter_snapshot().await; + for (id, (topic, _function_id)) in subscriptions.drain() { + adapter.unsubscribe(&topic, &id).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::sync::Mutex as StdMutex; + + #[derive(Default)] + struct RecordingAdapter { + published: StdMutex>, + subscribed: StdMutex>, + unsubscribed: StdMutex>, + } + + #[async_trait] + impl crate::adapters::PubSubAdapter for RecordingAdapter { + async fn publish(&self, topic: &str, data: serde_json::Value) { + self.published + .lock() + .unwrap() + .push((topic.to_string(), data)); + } + async fn subscribe(&self, topic: &str, id: &str, function_id: &str) { + self.subscribed.lock().unwrap().push(( + topic.to_string(), + id.to_string(), + function_id.to_string(), + )); + } + async fn unsubscribe(&self, topic: &str, id: &str) { + self.unsubscribed + .lock() + .unwrap() + .push((topic.to_string(), id.to_string())); + } + } + + #[tokio::test] + async fn publish_rejects_empty_topic() { + let adapter = Arc::new(RecordingAdapter::default()); + let hub = Hub::new(adapter.clone()); + let err = hub + .publish("", serde_json::json!({"x": 1})) + .await + .unwrap_err(); + assert!(err.contains("topic_not_set")); + assert!(err.contains("Topic is not set")); + assert!(adapter.published.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn publish_delegates_to_adapter() { + let adapter = Arc::new(RecordingAdapter::default()); + let hub = Hub::new(adapter.clone()); + hub.publish("orders", serde_json::json!({"id": 1})) + .await + .unwrap(); + let published = adapter.published.lock().unwrap(); + assert_eq!( + published.as_slice(), + &[("orders".to_string(), serde_json::json!({"id": 1}))] + ); + } + + #[tokio::test] + async fn subscribe_records_and_unsubscribe_finds_topic_by_id() { + let adapter = Arc::new(RecordingAdapter::default()); + let hub = Hub::new(adapter.clone()); + hub.subscribe("sub-1", "orders", "test::listener").await; + assert_eq!(hub.subscription_count().await, 1); + + // Only the id is known at unregister time (SDK stub). + hub.unsubscribe("sub-1").await; + assert_eq!(hub.subscription_count().await, 0); + assert_eq!( + adapter.unsubscribed.lock().unwrap().as_slice(), + &[("orders".to_string(), "sub-1".to_string())] + ); + } + + #[tokio::test] + async fn subscribe_with_empty_topic_is_noop() { + let adapter = Arc::new(RecordingAdapter::default()); + let hub = Hub::new(adapter.clone()); + hub.subscribe("sub-1", "", "test::listener").await; + assert_eq!(hub.subscription_count().await, 0); + assert!(adapter.subscribed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn unsubscribe_unknown_id_is_noop() { + let adapter = Arc::new(RecordingAdapter::default()); + let hub = Hub::new(adapter.clone()); + hub.unsubscribe("ghost").await; + assert!(adapter.unsubscribed.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn swap_resubscribes_new_backend_then_unsubscribes_old() { + let old = Arc::new(RecordingAdapter::default()); + let new = Arc::new(RecordingAdapter::default()); + let hub = Hub::new(old.clone()); + hub.subscribe("sub-1", "orders", "test::listener").await; + + hub.swap_adapter(new.clone()).await; + + assert_eq!( + new.subscribed.lock().unwrap().as_slice(), + &[( + "orders".to_string(), + "sub-1".to_string(), + "test::listener".to_string() + )], + "swap must re-subscribe the live subscription onto the new backend" + ); + assert_eq!( + old.unsubscribed.lock().unwrap().as_slice(), + &[("orders".to_string(), "sub-1".to_string())], + "swap must tear the live subscription off the previous backend" + ); + + // New publishes route through the new backend. + hub.publish("orders", serde_json::json!(1)).await.unwrap(); + assert_eq!(new.published.lock().unwrap().len(), 1); + assert!(old.published.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn shutdown_unsubscribes_everything() { + let adapter = Arc::new(RecordingAdapter::default()); + let hub = Hub::new(adapter.clone()); + hub.subscribe("sub-1", "orders", "fn::a").await; + hub.subscribe("sub-2", "invoices", "fn::b").await; + hub.shutdown().await; + assert_eq!(hub.subscription_count().await, 0); + assert_eq!(adapter.unsubscribed.lock().unwrap().len(), 2); + } +} diff --git a/pubsub/src/lib.rs b/pubsub/src/lib.rs new file mode 100644 index 000000000..094d4864f --- /dev/null +++ b/pubsub/src/lib.rs @@ -0,0 +1,18 @@ +/// The trigger type this worker registers. Always `subscribe` -- the built-in +/// `iii-pubsub` worker must be removed from the engine config before this +/// worker can boot (see [`boot::start`]'s guard). +pub const TRIGGER_TYPE: &str = "subscribe"; + +/// The service function this worker registers. The builtin registers the BARE +/// id `publish` (its `#[service(name = "pubsub")]` prefix is discarded by the +/// macro — engine/function-macros/src/lib.rs:243); the stream bridge calls +/// `publish` (engine/src/workers/stream/adapters/bridge.rs:102). Never rename. +pub const PUBLISH_FUNCTION_ID: &str = "publish"; + +pub mod adapters; +pub mod boot; +pub mod config; +pub mod configuration; +pub mod hub; +pub mod manifest; +pub mod trigger; diff --git a/pubsub/src/main.rs b/pubsub/src/main.rs new file mode 100644 index 000000000..8949f9b9b --- /dev/null +++ b/pubsub/src/main.rs @@ -0,0 +1,114 @@ +//! `iii-pubsub` binary entry. +//! +//! Boot: parse CLI, connect to the engine, register the `pubsub` configuration +//! schema, fetch the authoritative value, start the hub via `boot::start`, +//! wire the `configuration:updated` reload trigger, then sleep until Ctrl+C. +//! The optional `--config` YAML file is a seed only. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use iii_sdk::runtime::WorkerMetadata; +use iii_sdk::{register_worker, InitOptions}; + +use iii_pubsub::config::PubSubConfig; +use iii_pubsub::configuration; + +#[derive(Parser, Debug)] +#[command(name = "pubsub", about = "Pub/sub worker for iii.")] +struct Cli { + /// Optional seed config.yaml. Seeds the configuration entry only when + /// nothing is stored yet; thereafter the configuration worker is the + /// authoritative source. + #[arg(long)] + config: Option, + #[arg(long, default_value = "ws://127.0.0.1:49134")] + url: String, + #[arg(long)] + manifest: bool, +} + +fn worker_metadata() -> WorkerMetadata { + WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "pubsub".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + let cli = Cli::parse(); + if cli.manifest { + println!( + "{}", + serde_json::to_string_pretty(&iii_pubsub::manifest::build_manifest()).unwrap() + ); + return Ok(()); + } + + let seed: Option = match cli.config.as_deref() { + Some(path) => match std::fs::read_to_string(path) + .map_err(|e| e.to_string()) + .and_then(|s| serde_yaml::from_str::(&s).map_err(|e| e.to_string())) + { + Ok(c) => { + tracing::info!(path = %path, "loaded seed config"); + Some(c.normalized()) + } + Err(e) => { + tracing::warn!(path = %path, error = %e, "failed to load --config seed; continuing without seed"); + None + } + }, + None => None, + }; + + let iii = Arc::new(register_worker( + &cli.url, + InitOptions { + metadata: Some(worker_metadata()), + ..InitOptions::default() + }, + )); + + configuration::register_config(&iii, seed.as_ref()) + .await + .map_err(anyhow::Error::msg) + .context("registering pubsub configuration schema")?; + let config = configuration::fetch_config(&iii) + .await + .map_err(anyhow::Error::msg) + .context("loading pubsub configuration")?; + + let boot = iii_pubsub::boot::start(iii.clone(), config).await?; + tracing::info!( + adapter = %boot.config.read().await.effective_adapter_name(), + "iii-pubsub ready" + ); + + configuration::register_config_trigger( + &iii, + boot.hub.clone(), + boot.invoker.clone(), + boot.config.clone(), + boot.apply_lock.clone(), + ) + .map_err(anyhow::Error::msg) + .context("binding configuration trigger")?; + + tokio::signal::ctrl_c().await?; + tracing::info!("iii-pubsub shutting down"); + boot.shutdown().await; + iii.shutdown_async().await; + Ok(()) +} diff --git a/pubsub/src/manifest.rs b/pubsub/src/manifest.rs new file mode 100644 index 000000000..f3571077c --- /dev/null +++ b/pubsub/src/manifest.rs @@ -0,0 +1,32 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct ModuleManifest { + pub name: String, + pub version: String, + pub description: String, + pub default_config: serde_json::Value, + pub supported_targets: Vec, +} + +pub fn build_manifest() -> ModuleManifest { + ModuleManifest { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + description: "Topic-based publish/subscribe messaging for real-time event distribution." + .to_string(), + default_config: crate::config::PubSubConfig::default().to_json(), + supported_targets: vec![env!("TARGET").to_string()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn manifest_has_required_fields() { + let v = serde_json::to_value(build_manifest()).unwrap(); + assert_eq!(v["name"], env!("CARGO_PKG_NAME")); + assert!(!v["supported_targets"].as_array().unwrap().is_empty()); + } +} diff --git a/pubsub/src/trigger.rs b/pubsub/src/trigger.rs new file mode 100644 index 000000000..49c3c21fd --- /dev/null +++ b/pubsub/src/trigger.rs @@ -0,0 +1,125 @@ +//! Bridges engine `subscribe` trigger bindings into the hub. Mirrors +//! http/src/trigger.rs: keyed by trigger id because the SDK only populates +//! `id` on unregister. Topic parsing is lenient (builtin parity: a missing +//! topic warns and registers nothing — pubsub.rs:115-139). + +use std::sync::Arc; + +use async_trait::async_trait; +use iii_sdk::errors::Error; +use iii_sdk::trigger::{TriggerConfig, TriggerHandler}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::hub::Hub; + +/// Advertised trigger config schema — field parity with the engine's +/// `SubscribeTriggerConfig` (engine/src/trigger_formats.rs:135-141). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SubscribeTriggerSpec { + /// Topic to subscribe to + pub topic: String, + /// Optional function ID to evaluate before invoking handler. + /// Accepted for schema parity; the builtin never evaluated it and neither + /// does this worker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition_function_id: Option, +} + +pub struct SubscribeTriggerHandler { + pub hub: Arc, +} + +impl SubscribeTriggerHandler { + pub fn new(hub: Arc) -> Self { + Self { hub } + } +} + +#[async_trait] +impl TriggerHandler for SubscribeTriggerHandler { + async fn register_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + // Lenient like the builtin: missing/non-string topic -> "" -> the hub + // warns and registers nothing. Never an error. + let topic = config + .config + .get("topic") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + tracing::info!(topic = %topic, function_id = %config.function_id, "PubSub subscription registered"); + self.hub + .subscribe(&config.id, &topic, &config.function_id) + .await; + Ok(()) + } + + async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), Error> { + // Only `config.id` is populated (SDK stub); the hub knows the topic. + self.hub.unsubscribe(&config.id).await; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct NoopAdapter; + + #[async_trait] + impl crate::adapters::PubSubAdapter for NoopAdapter { + async fn publish(&self, _topic: &str, _data: serde_json::Value) {} + async fn subscribe(&self, _topic: &str, _id: &str, _function_id: &str) {} + async fn unsubscribe(&self, _topic: &str, _id: &str) {} + } + + fn handler() -> SubscribeTriggerHandler { + SubscribeTriggerHandler::new(Arc::new(Hub::new(Arc::new(NoopAdapter)))) + } + + fn trigger_config(id: &str, cfg: serde_json::Value) -> iii_sdk::trigger::TriggerConfig { + iii_sdk::trigger::TriggerConfig { + id: id.to_string(), + function_id: "test::listener".to_string(), + config: cfg, + metadata: None, + } + } + + #[tokio::test] + async fn register_subscribes_and_unregister_by_id_only() { + let h = handler(); + h.register_trigger(trigger_config("t1", serde_json::json!({"topic": "orders"}))) + .await + .unwrap(); + assert_eq!(h.hub.subscription_count().await, 1); + + // SDK sends a stub on unregister: only `id` is real. + h.unregister_trigger(trigger_config("t1", serde_json::Value::Null)) + .await + .unwrap(); + assert_eq!(h.hub.subscription_count().await, 0); + } + + #[tokio::test] + async fn register_without_topic_is_ok_but_registers_nothing() { + // Builtin parity: missing/empty topic warns and returns Ok. + let h = handler(); + h.register_trigger(trigger_config("t1", serde_json::json!({}))) + .await + .unwrap(); + h.register_trigger(trigger_config("t2", serde_json::json!({"topic": ""}))) + .await + .unwrap(); + assert_eq!(h.hub.subscription_count().await, 0); + } + + #[tokio::test] + async fn unregister_unknown_id_is_ok() { + let h = handler(); + h.unregister_trigger(trigger_config("ghost", serde_json::Value::Null)) + .await + .unwrap(); + } +} diff --git a/pubsub/tests/common/engine.rs b/pubsub/tests/common/engine.rs new file mode 100644 index 000000000..0e7353bdc --- /dev/null +++ b/pubsub/tests/common/engine.rs @@ -0,0 +1,105 @@ +#![allow(dead_code)] +//! Connect-or-skip wrapper around the iii SDK. +//! +//! These are e2e tests: they only make sense with a running engine. By +//! default, when the engine is unreachable, `get_or_init`/`connect_fresh` +//! return `None` and the caller skips the test — this keeps CI (which runs +//! with no engine) and casual local runs green. Set `III_E2E_REQUIRE` to any +//! value to fail loudly instead (for intentional e2e runs where a missing +//! engine should be treated as a bug). +//! +//! One engine connection per test binary process via `OnceCell` — the +//! WebSocket handshake dwarfs per-test overhead. +//! +//! Point the tests at an engine with `III_ENGINE_WS_URL` (default +//! `ws://127.0.0.1:49134`). + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::TriggerRequest; +use iii_sdk::{register_worker, IIIClient, InitOptions}; +use serde_json::json; +use tokio::sync::OnceCell; + +const DEFAULT_WS_URL: &str = "ws://127.0.0.1:49134"; + +static ENGINE: OnceCell>> = OnceCell::const_new(); + +pub fn ws_url() -> String { + std::env::var("III_ENGINE_WS_URL").unwrap_or_else(|_| DEFAULT_WS_URL.to_string()) +} + +/// Turn a failed connection attempt into either a panic (when +/// `III_E2E_REQUIRE` is set — an intentional e2e run that must fail loudly +/// on a missing engine) or a skip (the default — logs and returns `None` so +/// the caller can bail out of the test without failing it). +fn require_or_skip(connected: Option>) -> Option> { + if connected.is_some() { + return connected; + } + + if std::env::var("III_E2E_REQUIRE").is_ok() { + panic!( + "e2e requires a running iii engine at {} — start `iii` or set \ + III_ENGINE_WS_URL to point at one", + ws_url() + ); + } + + eprintln!( + "[skip] no iii engine reachable at {} — skipping e2e test (set III_E2E_REQUIRE=1 to \ + fail instead)", + ws_url() + ); + None +} + +async fn try_connect_raw() -> Option> { + let url = ws_url(); + let iii = Arc::new(register_worker(&url, InitOptions::default())); + + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(250)).await; + let probe = iii + .trigger(TriggerRequest { + function_id: "engine::workers::list".to_string(), + payload: json!({}), + action: None, + timeout_ms: Some(800), + }) + .await; + match probe { + Ok(_) => return Some(iii), + Err(Error::NotConnected) => continue, + Err(_) => continue, + } + } + + iii.shutdown_async().await; + None +} + +/// Open a fresh, dedicated engine connection (NOT the shared `OnceCell` one). +/// +/// Needed when a test registers a function id that another test in the same +/// binary also registers (e.g. the `publish` function): registering the same +/// id twice on one client panics, so such a test needs its own client. Returns +/// `None` (the caller should skip the test) if no engine is reachable, unless +/// `III_E2E_REQUIRE` is set, in which case it panics. The caller should +/// `shutdown_async()` the returned client when done. +pub async fn connect_fresh() -> Option> { + require_or_skip(try_connect_raw().await) +} + +/// Get-or-init the shared engine handle for this test binary. +/// +/// Returns `None` (the caller should skip the test) if no engine is +/// reachable, unless `III_E2E_REQUIRE` is set, in which case it panics. +pub async fn get_or_init() -> Option> { + ENGINE + .get_or_init(|| async { require_or_skip(try_connect_raw().await) }) + .await + .clone() +} diff --git a/pubsub/tests/common/mod.rs b/pubsub/tests/common/mod.rs new file mode 100644 index 000000000..cd348363b --- /dev/null +++ b/pubsub/tests/common/mod.rs @@ -0,0 +1,16 @@ +pub mod engine; + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// Waits until the counter reaches `n` (up to ~5s), else panics. +pub async fn wait_for_deliveries(counter: &Arc, n: usize) { + for _ in 0..50 { + if counter.load(Ordering::SeqCst) >= n { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("subscribers never reached {n} deliver(ies)"); +} diff --git a/pubsub/tests/e2e_pubsub.rs b/pubsub/tests/e2e_pubsub.rs new file mode 100644 index 000000000..5f015e8a0 --- /dev/null +++ b/pubsub/tests/e2e_pubsub.rs @@ -0,0 +1,112 @@ +mod common; + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; +use iii_sdk::RegisterFunction; +use serde_json::json; +use serial_test::serial; + +use common::engine; + +#[tokio::test] +#[serial] +async fn publish_fans_out_raw_data_to_subscribe_triggers() { + let Some(iii) = engine::get_or_init().await else { + return; // skip: engine absent + }; + + let boot = iii_pubsub::boot::start(iii.clone(), iii_pubsub::config::PubSubConfig::default()) + .await + .expect("pubsub worker should boot"); + + let deliveries = Arc::new(AtomicUsize::new(0)); + let seen = Arc::new(tokio::sync::Mutex::new(Vec::::new())); + for fn_id in ["e2e::pubsub-listener-a", "e2e::pubsub-listener-b"] { + let deliveries = deliveries.clone(); + let seen = seen.clone(); + iii.register_function( + fn_id, + RegisterFunction::new_async(move |payload: serde_json::Value| { + let deliveries = deliveries.clone(); + let seen = seen.clone(); + async move { + deliveries.fetch_add(1, Ordering::SeqCst); + seen.lock().await.push(payload); + Ok::<_, Error>(json!(null)) + } + }), + ); + } + + for fn_id in ["e2e::pubsub-listener-a", "e2e::pubsub-listener-b"] { + iii.register_trigger(RegisterTriggerInput { + trigger_type: "subscribe".to_string(), + function_id: fn_id.to_string(), + config: json!({"topic": "e2e.orders"}), + metadata: None, + }) + .expect("trigger registration"); + } + + // Give the engine a beat to route the trigger bindings to this worker. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // Call `publish` over the bus — the EXACT path external callers use today. + let result = iii + .trigger(TriggerRequest { + function_id: "publish".to_string(), + payload: json!({"topic": "e2e.orders", "data": {"id": 42}}), + action: None, + timeout_ms: Some(5000), + }) + .await + .expect("publish should succeed"); + assert!( + result.is_null(), + "publish returns a null result (builtin parity)" + ); + + common::wait_for_deliveries(&deliveries, 2).await; + let seen = seen.lock().await; + for payload in seen.iter() { + // Parity: the subscriber receives the RAW published data, no envelope. + // The engine additionally stamps cross-worker invocation payloads with + // caller metadata (e.g. `_caller_worker_id`) — identical for the + // builtin's `engine.call` — so assert the published field is present + // rather than exact-equal. + assert_eq!(payload.get("id"), Some(&json!(42))); + } + + boot.shutdown().await; +} + +#[tokio::test] +#[serial] +async fn publish_with_empty_topic_fails_with_topic_not_set() { + // Own client (connect_fresh) so booting the worker here doesn't collide + // with the first test's `publish` registration on the shared client. + let Some(iii) = engine::connect_fresh().await else { + return; // skip: engine absent + }; + + let boot = iii_pubsub::boot::start(iii.clone(), iii_pubsub::config::PubSubConfig::default()) + .await + .expect("pubsub worker should boot"); + + let err = iii + .trigger(TriggerRequest { + function_id: "publish".to_string(), + payload: json!({"topic": "", "data": {}}), + action: None, + timeout_ms: Some(5000), + }) + .await + .expect_err("empty topic must fail"); + assert!(err.to_string().contains("topic_not_set"), "got: {err}"); + + boot.shutdown().await; + iii.shutdown_async().await; +}