From 730de334e070f611a31bb9aec276d44cdf80ad76 Mon Sep 17 00:00:00 2001 From: Mark Kalsbeek Date: Mon, 25 May 2026 23:14:32 +0200 Subject: [PATCH] baseline --- Cargo.lock | 2293 +++++++++++++++++++++++++++++++++ Cargo.toml | 11 + axum-server/Cargo.toml | 26 + axum-server/src/main.rs | 585 +++++++++ loadgen/common.lua | 12 + loadgen/s1_ping.lua | 5 + loadgen/s2_user_get.lua | 13 + loadgen/s3_mixed.lua | 30 + run_all.sh | 411 ++++++ runner.sh | 367 ++++++ shell.nix | 66 + urus-bench-spec.md | 462 +++++++ urus-server/Cargo.toml | 35 + urus-server/src/handlers.rs | 192 +++ urus-server/src/main.rs | 194 +++ urus-server/src/middleware.rs | 234 ++++ urus-server/src/store.rs | 444 +++++++ 17 files changed, 5380 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 axum-server/Cargo.toml create mode 100644 axum-server/src/main.rs create mode 100644 loadgen/common.lua create mode 100644 loadgen/s1_ping.lua create mode 100644 loadgen/s2_user_get.lua create mode 100644 loadgen/s3_mixed.lua create mode 100755 run_all.sh create mode 100755 runner.sh create mode 100644 shell.nix create mode 100644 urus-bench-spec.md create mode 100644 urus-server/Cargo.toml create mode 100644 urus-server/src/handlers.rs create mode 100644 urus-server/src/main.rs create mode 100644 urus-server/src/middleware.rs create mode 100644 urus-server/src/store.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a666658 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2293 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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 = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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 = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "axum-server" +version = "0.1.0" +dependencies = [ + "axum", + "http", + "serde", + "serde_json", + "sqlx", + "subtle", + "tokio", + "tower", + "tower-http", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[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 = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +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 = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[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 = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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 = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "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-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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-core", + "futures-io", + "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", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[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.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[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-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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 = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[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 = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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 = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[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.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.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_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_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smarm" +version = "0.3.0" +dependencies = [ + "libc", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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" + +[[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 = "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 = "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", + "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-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 = "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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "base64 0.21.7", + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "uuid", +] + +[[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 = [ + "log", + "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", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "urus" +version = "0.1.0" +dependencies = [ + "httparse", + "libc", + "smarm", +] + +[[package]] +name = "urus-server" +version = "0.1.0" +dependencies = [ + "crossbeam-queue", + "rusqlite", + "serde", + "serde_json", + "smarm", + "subtle", + "urus", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +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.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +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.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[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/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..146a31e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +resolver = "2" +members = [ + "urus-server", + "axum-server", +] + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "unwind" diff --git a/axum-server/Cargo.toml b/axum-server/Cargo.toml new file mode 100644 index 0000000..a012935 --- /dev/null +++ b/axum-server/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "axum-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "net"] } +tower = "0.5" +tower-http = { version = "0.5", features = ["request-id", "auth"] } +http = "1" + +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +subtle = "2" + +# We deliberately use sqlx for the SQLite backing store on this side +# (rather than rusqlite) because that's the idiomatic axum/tokio choice. +# Both end up calling the same SQLite C library; what matters for the +# bench is that each framework uses the idiomatic-on-that-side option. +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros"] } + +[[bin]] +name = "axum-server" +path = "src/main.rs" diff --git a/axum-server/src/main.rs b/axum-server/src/main.rs new file mode 100644 index 0000000..ed34341 --- /dev/null +++ b/axum-server/src/main.rs @@ -0,0 +1,585 @@ +//! axum-server — the hyper/axum side of the urus / axum / cowboy benchmark. +//! +//! Exposes the same routes as urus-server with materially the same +//! middleware stack: +//! logger (ring-buffer, no stdout) -> request_id -> auth -> router +//! +//! Backing store is switchable: +//! --store=memory Mutex> +//! --store=sqlite sqlx::SqlitePool in WAL mode +//! +//! Routes: +//! GET /ping naked (bypasses auth) +//! GET /api/v1/users list (cap 100) +//! POST /api/v1/users create +//! GET /api/v1/users/:id get +//! PUT /api/v1/users/:id update +//! DELETE /api/v1/users/:id delete + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{header, HeaderValue, Request, StatusCode}, + middleware::{self, Next as AxumNext}, + response::Response, + routing::get, + Router, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, SqlitePool}; +use subtle::ConstantTimeEq; +use tokio::sync::Mutex; + +// --------------------------------------------------------------------------- +// CLI (hand-rolled, same shape as urus-server) +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct Cli { + addr: String, + store: StoreKind, + token: String, + db_path: String, + no_auth: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum StoreKind { Memory, Sqlite } + +fn parse_cli() -> Result { + let mut cli = Cli { + addr: "127.0.0.1:8080".into(), + store: StoreKind::Memory, + token: "test-token-aaaaaaaaaaaaaaaaaaaa".into(), + db_path: "/tmp/axum-bench.sqlite".into(), + no_auth: false, + }; + for arg in std::env::args().skip(1) { + let (k, v) = match arg.split_once('=') { + Some((k, v)) => (k.to_string(), Some(v.to_string())), + None => (arg.clone(), None), + }; + match (k.as_str(), v) { + ("--addr", Some(v)) => cli.addr = v, + ("--token", Some(v)) => cli.token = v, + ("--db-path", Some(v)) => cli.db_path = v, + ("--store", Some(v)) => cli.store = match v.as_str() { + "memory" => StoreKind::Memory, + "sqlite" => StoreKind::Sqlite, + other => return Err(format!("unknown store: {other}")), + }, + ("--no-auth", None) => cli.no_auth = true, + ("--help" | "-h", _) => { print_usage(); std::process::exit(0); } + (k, _) => return Err(format!("unknown flag: {k}")), + } + } + Ok(cli) +} + +fn print_usage() { + eprintln!("axum-server [--addr=HOST:PORT] [--store=memory|sqlite] \ + [--token=TOKEN] [--db-path=PATH] [--no-auth]"); +} + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Serialize, Deserialize, sqlx::FromRow)] +struct User { + id: i64, + name: String, + email: String, +} + +#[derive(Deserialize)] +struct NewUser { + name: String, + email: String, +} + +// --------------------------------------------------------------------------- +// Store trait — memory and sqlite implementations behind one type. +// --------------------------------------------------------------------------- +// +// We use an enum rather than a trait object so handler code stays +// monomorphic. Both variants are async (sqlx is genuinely async; the +// memory variant uses a tokio Mutex for parity — std::sync::Mutex inside +// an async handler is a known footgun, even though for our short +// critical sections it'd be measurably faster). + +#[derive(Clone)] +enum Store { + Memory(Arc), + Sqlite(SqlitePool), +} + +struct MemoryStore { + map: Mutex>, + next_id: AtomicU64, +} + +impl MemoryStore { + fn new() -> Self { + Self { + map: Mutex::new(HashMap::with_capacity(1024)), + next_id: AtomicU64::new(1), + } + } +} + +async fn open_sqlite(path: &str) -> Result { + let opts = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .busy_timeout(std::time::Duration::from_secs(5)); + + // Pool sized to taste; 8 connections matches urus-side's 1 writer + 4 + // readers plus headroom for axum's tokio worker count. + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(opts) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL + );" + ).execute(&pool).await?; + + Ok(pool) +} + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct AppState { + store: Store, + log_ring: LogRing, +} + +// --------------------------------------------------------------------------- +// Bounded log ring — analogue of urus-server's LogRing. +// --------------------------------------------------------------------------- +// +// We can't easily use the same crossbeam ArrayQueue type because axum's +// middleware closures aren't `&` (`tokio::sync::Mutex` is the wrong +// shape here — we want lock-free push). A simple wraparound slot-array +// via atomics would be ideal, but the simplest correct thing that +// matches urus's behavioural guarantee (bounded, drops on overflow, +// never blocks) is `std::sync::Mutex` with a capacity. The +// critical section is microseconds long. + +#[derive(Clone)] +struct LogRing { + inner: Arc>>, + cap: usize, +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +struct LogEntry { + method: String, + path: String, + status: u16, + elapsed_us: u64, +} + +impl LogRing { + fn with_capacity(n: usize) -> Self { + Self { + inner: Arc::new(std::sync::Mutex::new( + std::collections::VecDeque::with_capacity(n))), + cap: n, + } + } + fn push(&self, e: LogEntry) { + if let Ok(mut q) = self.inner.lock() { + if q.len() >= self.cap { q.pop_front(); } + q.push_back(e); + } + } +} + +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- +// +// We hand-write three middleware functions rather than reaching for +// tower-http for everything. Reason: tower-http's auth and request-id +// layers are great, but for the bench we want behavioural parity with +// urus-server's plugs — same observable behaviour, same trivial work +// shape. Mixing tower-http for some and hand-rolled for others would +// confuse the comparison. + +async fn logger_mw( + State(state): State, + req: Request, + next: AxumNext, +) -> Response { + let start = Instant::now(); + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + + let response = next.run(req).await; + + let elapsed_us = start.elapsed().as_micros() as u64; + state.log_ring.push(LogEntry { + method, + path, + status: response.status().as_u16(), + elapsed_us, + }); + response +} + +async fn request_id_mw(req: Request, next: AxumNext) -> Response { + + let mut raw = [0u8; 12]; + fill_random_bytes(&mut raw); + let id = b32_encode(&raw); + + let mut resp = next.run(req).await; + if let Ok(v) = HeaderValue::from_str(&id) { + resp.headers_mut().insert("x-request-id", v); + } + resp +} + +fn b32_encode(input: &[u8; 12]) -> String { + static ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::with_capacity(20); + let mut buf: u64 = 0; + let mut bits: u32 = 0; + for &b in input { + buf = (buf << 8) | (b as u64); + bits += 8; + while bits >= 5 { + bits -= 5; + out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char); + } + } + if bits > 0 { + out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char); + } + out +} + +// SplitMix64-based RNG, same as urus-server's. Kept identical so the +// request_id middleware costs are comparable. +thread_local! { + static RNG_STATE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn splitmix64(s: u64) -> (u64, u64) { + let new_state = s.wrapping_add(0x9E3779B97F4A7C15); + let mut z = new_state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + (new_state, z ^ (z >> 31)) +} + +fn fill_random_bytes(out: &mut [u8; 12]) { + RNG_STATE.with(|cell| { + let mut s = cell.get(); + if s == 0 { + let p = cell as *const _ as usize as u64; + let t = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64).unwrap_or(0); + s = p ^ t ^ 0xA5A5_A5A5_A5A5_A5A5; + if s == 0 { s = 1; } + } + let (s1, r1) = splitmix64(s); + let (s2, r2) = splitmix64(s1); + cell.set(s2); + out[0..8].copy_from_slice(&r1.to_le_bytes()); + out[8..12].copy_from_slice(&r2.to_le_bytes()[..4]); + }); +} + +async fn auth_mw( + State(token): State>>, + req: Request, + next: AxumNext, +) -> Response { + // /ping bypass, same exception as urus-server (see middleware.rs in + // that crate for the rationale). + if req.uri().path() == "/ping" { + return next.run(req).await; + } + + let presented = req.headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")) + .map(str::as_bytes); + + let ok = match presented { + Some(p) if p.len() == token.len() => bool::from(p.ct_eq(&token)), + _ => false, + }; + + if ok { + next.run(req).await + } else { + let body = b"{\"error\":\"unauthorized\"}"; + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(&body[..])) + .unwrap() + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async fn h_ping() -> (StatusCode, [(header::HeaderName, &'static str); 1], &'static str) { + (StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], "pong") +} + +async fn h_list(State(state): State) -> Response { + let users: Vec = match &state.store { + Store::Memory(m) => { + let g = m.map.lock().await; + g.values().take(100).cloned().collect() + } + Store::Sqlite(p) => { + match sqlx::query_as::<_, User>("SELECT id, name, email FROM users LIMIT 100") + .fetch_all(p).await { + Ok(v) => v, + Err(_) => return db_error(), + } + } + }; + json_response(StatusCode::OK, &users) +} + +async fn h_get( + State(state): State, + Path(id): Path, +) -> Response { + match &state.store { + Store::Memory(m) => { + let g = m.map.lock().await; + match g.get(&id) { + Some(u) => json_response(StatusCode::OK, u), + None => not_found(), + } + } + Store::Sqlite(p) => { + match sqlx::query_as::<_, User>("SELECT id, name, email FROM users WHERE id=?1") + .bind(id as i64).fetch_optional(p).await { + Ok(Some(u)) => json_response(StatusCode::OK, &u), + Ok(None) => not_found(), + Err(_) => db_error(), + } + } + } +} + +async fn h_create( + State(state): State, + body: Bytes, +) -> Response { + let nu: NewUser = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return bad_request(), + }; + match &state.store { + Store::Memory(m) => { + let id = m.next_id.fetch_add(1, Ordering::Relaxed); + let u = User { id: id as i64, name: nu.name, email: nu.email }; + let mut g = m.map.lock().await; + // bounded growth same as urus-side + if g.len() > 100_000 { + let to_remove: Vec = g.keys().take(50_000).copied().collect(); + for k in to_remove { g.remove(&k); } + } + g.insert(id, u.clone()); + json_response(StatusCode::CREATED, &u) + } + Store::Sqlite(p) => { + let res = sqlx::query("INSERT INTO users (name, email) VALUES (?1, ?2)") + .bind(&nu.name).bind(&nu.email) + .execute(p).await; + match res { + Ok(r) => { + let u = User { id: r.last_insert_rowid(), name: nu.name, email: nu.email }; + json_response(StatusCode::CREATED, &u) + } + Err(_) => db_error(), + } + } + } +} + +async fn h_update( + State(state): State, + Path(id): Path, + body: Bytes, +) -> Response { + let nu: NewUser = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return bad_request(), + }; + match &state.store { + Store::Memory(m) => { + let mut g = m.map.lock().await; + match g.get_mut(&id) { + Some(u) => { + u.name = nu.name; + u.email = nu.email; + let snap = u.clone(); + json_response(StatusCode::OK, &snap) + } + None => not_found(), + } + } + Store::Sqlite(p) => { + let res = sqlx::query("UPDATE users SET name=?1, email=?2 WHERE id=?3") + .bind(&nu.name).bind(&nu.email).bind(id as i64) + .execute(p).await; + match res { + Ok(r) if r.rows_affected() > 0 => { + let u = User { id: id as i64, name: nu.name, email: nu.email }; + json_response(StatusCode::OK, &u) + } + Ok(_) => not_found(), + Err(_) => db_error(), + } + } + } +} + +async fn h_delete( + State(state): State, + Path(id): Path, +) -> Response { + match &state.store { + Store::Memory(m) => { + let mut g = m.map.lock().await; + match g.remove(&id) { + Some(_) => Response::builder() + .status(StatusCode::NO_CONTENT) + .body(axum::body::Body::empty()).unwrap(), + None => not_found(), + } + } + Store::Sqlite(p) => { + let res = sqlx::query("DELETE FROM users WHERE id=?1") + .bind(id as i64).execute(p).await; + match res { + Ok(r) if r.rows_affected() > 0 => Response::builder() + .status(StatusCode::NO_CONTENT) + .body(axum::body::Body::empty()).unwrap(), + Ok(_) => not_found(), + Err(_) => db_error(), + } + } + } +} + +// --------------------------------------------------------------------------- +// Response helpers +// --------------------------------------------------------------------------- + +fn json_response(status: StatusCode, body: &T) -> Response { + match serde_json::to_vec(body) { + Ok(bytes) => Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(bytes)) + .unwrap(), + Err(_) => db_error(), + } +} + +fn not_found() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(&b"{\"error\":\"not found\"}"[..])) + .unwrap() +} + +fn bad_request() -> Response { + Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(&b"{\"error\":\"invalid body\"}"[..])) + .unwrap() +} + +fn db_error() -> Response { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(&b"{\"error\":\"db\"}"[..])) + .unwrap() +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<(), Box> { + let cli = match parse_cli() { + Ok(c) => c, + Err(e) => { eprintln!("axum-server: {e}"); print_usage(); std::process::exit(2); } + }; + let addr: SocketAddr = cli.addr.parse()?; + + let store = match cli.store { + StoreKind::Memory => Store::Memory(Arc::new(MemoryStore::new())), + StoreKind::Sqlite => Store::Sqlite(open_sqlite(&cli.db_path).await?), + }; + let state = AppState { + store, + log_ring: LogRing::with_capacity(64 * 1024), + }; + + let token: Arc> = Arc::new(cli.token.as_bytes().to_vec()); + + // Build the router. /ping is on the same router; the auth middleware + // is path-aware and lets /ping through (see auth_mw). + let api_router: Router = Router::new() + .route("/api/v1/users", get(h_list).post(h_create)) + .route("/api/v1/users/:id", get(h_get).put(h_update).delete(h_delete)) + .route("/ping", get(h_ping)); + + let mut app = api_router.with_state(state.clone()); + + // Apply middleware in inside-out order so the request flow ends up + // matching urus-server: logger (outermost) -> request_id -> auth. + // axum applies middleware nearest-the-handler first, so the layer + // call order is: auth then request_id then logger. + if !cli.no_auth { + app = app.layer(middleware::from_fn_with_state(token, auth_mw)); + } + app = app + .layer(middleware::from_fn(request_id_mw)) + .layer(middleware::from_fn_with_state(state, logger_mw)); + + eprintln!( + "axum-server: store={:?} addr={} auth={}", + cli.store, cli.addr, !cli.no_auth + ); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/loadgen/common.lua b/loadgen/common.lua new file mode 100644 index 0000000..9bb1e4c --- /dev/null +++ b/loadgen/common.lua @@ -0,0 +1,12 @@ +-- Shared header config; required by s2/s3/s4 scripts. +-- Override BEARER / HOST via env vars when invoking wrk2. + +local M = {} +M.token = os.getenv("BEARER") or "test-token-aaaaaaaaaaaaaaaaaaaa" +M.host = os.getenv("HOST") or "127.0.0.1:8080" +M.headers = { + ["Authorization"] = "Bearer " .. M.token, + ["Connection"] = "keep-alive", + ["Host"] = M.host, +} +return M diff --git a/loadgen/s1_ping.lua b/loadgen/s1_ping.lua new file mode 100644 index 0000000..f6141af --- /dev/null +++ b/loadgen/s1_ping.lua @@ -0,0 +1,5 @@ +-- S1: naked /ping. No auth header (the server's auth_plug exempts /ping, +-- so sending a header would just add noise to the comparison). +wrk.method = "GET" +wrk.path = "/ping" +wrk.headers["Connection"] = "keep-alive" diff --git a/loadgen/s2_user_get.lua b/loadgen/s2_user_get.lua new file mode 100644 index 0000000..d877f90 --- /dev/null +++ b/loadgen/s2_user_get.lua @@ -0,0 +1,13 @@ +-- S2: single-route GET with full middleware (logger + request_id + auth). +-- :id is spread across 10_000 values to defeat per-id caching. + +local common = require("common") +wrk.method = "GET" +wrk.headers = common.headers + +math.randomseed(os.time() + os.getpid()) + +request = function() + local id = math.random(1, 10000) + return wrk.format(nil, "/api/v1/users/" .. id) +end diff --git a/loadgen/s3_mixed.lua b/loadgen/s3_mixed.lua new file mode 100644 index 0000000..19546ec --- /dev/null +++ b/loadgen/s3_mixed.lua @@ -0,0 +1,30 @@ +-- S3 (+S4): 80% GET single / 15% GET list / 5% POST. +-- The mix exposes the store-actor channel round-trip on urus, mutex +-- contention on axum, and gen_server message-pass on cowboy without +-- making writes the bottleneck. + +local common = require("common") +wrk.headers = common.headers +wrk.headers["Content-Type"] = "application/json" + +math.randomseed(os.time() + os.getpid()) + +-- A small pool of pre-built JSON bodies. Building one fresh per request +-- in Lua would taint the bench with Lua's GC overhead. +local body_pool = {} +for i = 1, 64 do + body_pool[i] = string.format( + [[{"name":"user%d","email":"u%d@example.test"}]], i, i) +end + +request = function() + local r = math.random() + if r < 0.80 then + return wrk.format("GET", "/api/v1/users/" .. math.random(1, 10000)) + elseif r < 0.95 then + return wrk.format("GET", "/api/v1/users") + else + local body = body_pool[math.random(1, #body_pool)] + return wrk.format("POST", "/api/v1/users", nil, body) + end +end diff --git a/run_all.sh b/run_all.sh new file mode 100755 index 0000000..64bb0f8 --- /dev/null +++ b/run_all.sh @@ -0,0 +1,411 @@ +#!/usr/bin/env bash +# urus-bench/run_all.sh +# +# Runs the full (server, scenario) matrix and writes a summary.md. +# +# Default matrix (8 runs): +# urus-mem × s1, s2, s3 +# axum-mem × s1, s2, s3 +# urus-sqlite × s4 +# axum-sqlite × s4 +# +# Cowboy is not built; skipped. +# +# Usage: +# ./run_all.sh # full matrix, defaults +# ./run_all.sh --quick # short timings (15s+15s+15s) +# ./run_all.sh --scenarios=s1,s2 # subset of scenarios +# ./run_all.sh --servers=urus-mem # subset of servers +# ./run_all.sh --batch=foo # batch label (default: timestamp) +# +# Per-run env vars (PROBE_SEC, WARMUP_SEC, MEASURE_SEC, PREPOP, SERVER_CPUS, +# LOADGEN_CPUS, WRK_CONNS, WRK_THREADS, SAT_RATIO) are passed through to +# runner.sh; --quick is just a preset for the timings. +# +# Output: +# results// batch dir +# -/ per-run dir (from runner.sh) +# summary.md aggregated headline table +# index.json machine-readable index of all runs + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +ALL_SERVERS_MEM=(urus-mem axum-mem) +ALL_SERVERS_SQLITE=(urus-sqlite axum-sqlite) +ALL_SCENARIOS_MEM=(s1 s2 s3) +ALL_SCENARIOS_SQLITE=(s4) + +SERVERS_FILTER="" +SCENARIOS_FILTER="" +BATCH="" +QUICK=0 + +# --------------------------------------------------------------------------- +# Arg parsing +# --------------------------------------------------------------------------- + +for arg in "$@"; do + case "$arg" in + --quick) QUICK=1 ;; + --servers=*) SERVERS_FILTER="${arg#*=}" ;; + --scenarios=*) SCENARIOS_FILTER="${arg#*=}" ;; + --batch=*) BATCH="${arg#*=}" ;; + -h|--help) + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) + echo "run_all: unknown arg: $arg" >&2 + exit 2 ;; + esac +done + +if [[ "$QUICK" == "1" ]]; then + export PROBE_SEC="${PROBE_SEC:-15}" + export WARMUP_SEC="${WARMUP_SEC:-15}" + export MEASURE_SEC="${MEASURE_SEC:-15}" +fi + +BATCH="${BATCH:-$(date +%Y%m%d-%H%M%S)}" +BATCH_DIR="${REPO_ROOT}/results/${BATCH}" +mkdir -p "$BATCH_DIR" + +# --------------------------------------------------------------------------- +# Build the matrix +# --------------------------------------------------------------------------- +# +# A run is a pair " ". We compute the full matrix, then +# apply filters. s1/s2/s3 always pair with the memory backends; s4 only +# pairs with sqlite backends. + +contains() { + # contains "needle" "csv-list" → 0 if needle in csv (or csv empty), else 1 + local needle="$1" haystack="$2" + [[ -z "$haystack" ]] && return 0 + local IFS=, + for item in $haystack; do + [[ "$item" == "$needle" ]] && return 0 + done + return 1 +} + +runs=() +for srv in "${ALL_SERVERS_MEM[@]}"; do + contains "$srv" "$SERVERS_FILTER" || continue + for sc in "${ALL_SCENARIOS_MEM[@]}"; do + contains "$sc" "$SCENARIOS_FILTER" || continue + runs+=("$srv $sc") + done +done +for srv in "${ALL_SERVERS_SQLITE[@]}"; do + contains "$srv" "$SERVERS_FILTER" || continue + for sc in "${ALL_SCENARIOS_SQLITE[@]}"; do + contains "$sc" "$SCENARIOS_FILTER" || continue + runs+=("$srv $sc") + done +done + +if [[ "${#runs[@]}" -eq 0 ]]; then + echo "run_all: empty matrix (filters too narrow?)" >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Build binaries up front +# --------------------------------------------------------------------------- +# +# We always rebuild in --release. If urus-server or axum-server is not +# requested, cargo will still build the workspace, but that's fine — a +# 1-minute one-time cost beats a confusing "binary not found" mid-batch. + +echo "run_all: building binaries (release)..." +( cd "$REPO_ROOT" && cargo build --release ) || { + echo "run_all: cargo build failed" >&2 + exit 3 +} + +# --------------------------------------------------------------------------- +# Run the matrix +# --------------------------------------------------------------------------- + +total="${#runs[@]}" +echo +echo "run_all: batch=${BATCH}, ${total} run(s):" +i=0 +for r in "${runs[@]}"; do + i=$((i + 1)) + printf " [%d/%d] %s\n" "$i" "$total" "$r" +done +echo + +i=0 +failed=() +succeeded=() +START_TS=$(date +%s) + +# Per-run port offset so concurrent stale processes (if any) don't collide. +BASE_PORT="${BASE_PORT:-8080}" + +for r in "${runs[@]}"; do + i=$((i + 1)) + set -- $r + SERVER="$1"; SCENARIO="$2" + RUN_ID="${SERVER}-${SCENARIO}" + RUN_DIR="${BATCH_DIR}/${RUN_ID}" + + printf '\n========== [%d/%d] %-12s %-3s ==========\n' \ + "$i" "$total" "$SERVER" "$SCENARIO" + + # Each run gets its own port so a hung server from a previous run can't + # silently keep answering on the default port. + PORT=$((BASE_PORT + i)) + + # Each run gets a unique results dir under the batch dir. runner.sh + # writes to results// at the repo root by default — we pass the + # run_id but also need it to land under the batch dir. Cheapest fix: + # let runner write to its default location, then move it under batch. + if PORT="$PORT" "${REPO_ROOT}/runner.sh" "$SERVER" "$SCENARIO" "$RUN_ID" \ + > "${BATCH_DIR}/${RUN_ID}.stdout.log" 2>&1; then + # Move the runner's output dir into the batch dir. + if [[ -d "${REPO_ROOT}/results/${RUN_ID}" ]]; then + rm -rf "$RUN_DIR" + mv "${REPO_ROOT}/results/${RUN_ID}" "$RUN_DIR" + fi + # Move the stdout log next to the run. + mv "${BATCH_DIR}/${RUN_ID}.stdout.log" "${RUN_DIR}/run_all.log" 2>/dev/null || true + + # Pretty-print the headline line from the result. + if [[ -f "${RUN_DIR}/result.json" ]]; then + rps=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "${RUN_DIR}/result.json") + p99=$(awk -F'"' '/"p99"/ {print $4; exit}' "${RUN_DIR}/result.json") + printf ' sustained=%s rps p99=%s\n' "$rps" "$p99" + fi + succeeded+=("$r") + else + echo " FAILED (see ${BATCH_DIR}/${RUN_ID}.stdout.log)" + failed+=("$r") + # Also try to salvage anything runner left behind for debugging. + if [[ -d "${REPO_ROOT}/results/${RUN_ID}" ]]; then + mkdir -p "${BATCH_DIR}/${RUN_ID}" + mv "${REPO_ROOT}/results/${RUN_ID}"/* "${BATCH_DIR}/${RUN_ID}/" 2>/dev/null || true + rmdir "${REPO_ROOT}/results/${RUN_ID}" 2>/dev/null || true + fi + fi +done + +END_TS=$(date +%s) +ELAPSED=$((END_TS - START_TS)) + +# --------------------------------------------------------------------------- +# Build summary.md +# --------------------------------------------------------------------------- +# +# The headline-table shape from urus-bench-spec.md §6 has servers as +# columns, scenarios as rows, two metrics per scenario (RPS sustained, +# p99). We render whatever subset of the matrix actually ran. + +SUMMARY="${BATCH_DIR}/summary.md" +INDEX="${BATCH_DIR}/index.json" + +# Discover every server / scenario actually represented in the batch. +declare -A by_run # by_run["server scenario"] = path to result.json +servers_seen=() +scenarios_seen=() +for d in "$BATCH_DIR"/*/; do + rj="${d}result.json" + [[ -f "$rj" ]] || continue + srv=$(awk -F'"' '/"server":/ {print $4; exit}' "$rj") + scn=$(awk -F'"' '/"scenario":/ {print $4; exit}' "$rj") + [[ -z "$srv" || -z "$scn" ]] && continue + by_run["$srv $scn"]="$rj" + # Track distinct lists (order preserved by first occurrence). + if ! printf '%s\n' "${servers_seen[@]}" | grep -qx "$srv"; then + servers_seen+=("$srv") + fi + if ! printf '%s\n' "${scenarios_seen[@]}" | grep -qx "$scn"; then + scenarios_seen+=("$scn") + fi +done + +# Sort scenarios in canonical order s1..s4. +IFS=$'\n' scenarios_seen=($(printf '%s\n' "${scenarios_seen[@]}" | sort)) +unset IFS + +# --------------------------------------------------------------------------- +# Emit summary.md +# --------------------------------------------------------------------------- + +{ + echo "# urus-bench summary" + echo + echo "- **Batch:** \`${BATCH}\`" + echo "- **Host:** \`$(uname -n)\` (\`$(uname -srm)\`)" + echo "- **CPUs:** \`$(nproc) cores\`" + if [[ -r /proc/cpuinfo ]]; then + model=$(awk -F: '/^model name/ {print $2; exit}' /proc/cpuinfo | sed 's/^ *//') + [[ -n "$model" ]] && echo "- **Model:** ${model}" + fi + echo "- **Started:** \`$(date -d "@$START_TS" -Iseconds 2>/dev/null || date -Iseconds)\`" + echo "- **Elapsed:** ${ELAPSED}s" + echo "- **Successful runs:** ${#succeeded[@]} / ${total}" + if [[ "${#failed[@]}" -gt 0 ]]; then + echo "- **Failed runs:** ${failed[*]}" + fi + echo + echo "Timings: probe=${PROBE_SEC:-30}s, warmup=${WARMUP_SEC:-30}s, measure=${MEASURE_SEC:-60}s, sat_ratio=${SAT_RATIO:-0.70}" + echo + + if [[ "${#servers_seen[@]}" -eq 0 ]]; then + echo "_No successful runs to summarize._" + exit 0 + fi + + # --- Sustained RPS table ----------------------------------------------- + echo "## Sustained RPS (wrk2 @ ${SAT_RATIO:-0.70} of saturation)" + echo + printf '| ' + for srv in "${servers_seen[@]}"; do printf '| %-14s ' "$srv"; done + printf '|\n' + printf '|------------' + for _ in "${servers_seen[@]}"; do printf '|----------------'; done + printf '|\n' + for scn in "${scenarios_seen[@]}"; do + printf '| %-10s ' "$scn" + for srv in "${servers_seen[@]}"; do + rj="${by_run["$srv $scn"]:-}" + if [[ -n "$rj" ]]; then + rps=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "$rj") + # round to integer for readability + rps=$(awk -v x="$rps" 'BEGIN {printf "%d", x+0.5}') + printf '| %14s ' "$rps" + else + printf '| %14s ' "—" + fi + done + printf '|\n' + done + echo + + # --- p99 table ---------------------------------------------------------- + echo "## p99 latency (at target rate)" + echo + printf '| ' + for srv in "${servers_seen[@]}"; do printf '| %-14s ' "$srv"; done + printf '|\n' + printf '|------------' + for _ in "${servers_seen[@]}"; do printf '|----------------'; done + printf '|\n' + for scn in "${scenarios_seen[@]}"; do + printf '| %-10s ' "$scn" + for srv in "${servers_seen[@]}"; do + rj="${by_run["$srv $scn"]:-}" + if [[ -n "$rj" ]]; then + p99=$(awk -F'"' '/"p99"/ {print $4; exit}' "$rj") + printf '| %14s ' "$p99" + else + printf '| %14s ' "—" + fi + done + printf '|\n' + done + echo + + # --- Spec pass/fail check, urus vs axum -------------------------------- + # Per spec §6: urus RPS >= 50% of axum RPS on the same scenario. + printf '## Spec checks (urus vs axum)\n\n' + printf '| Scenario | urus RPS | axum RPS | urus/axum | within 2× of axum |\n' + printf '|----------|----------|----------|-----------|-------------------|\n' + for scn in "${scenarios_seen[@]}"; do + # Match urus-{mem,sqlite} against axum-{mem,sqlite} for the matching tier. + urus_run=""; axum_run="" + for k in "${!by_run[@]}"; do + set -- $k + s="$1"; sc="$2" + [[ "$sc" != "$scn" ]] && continue + case "$s" in + urus-*) urus_run="${by_run[$k]}" ;; + axum-*) axum_run="${by_run[$k]}" ;; + esac + done + if [[ -n "$urus_run" && -n "$axum_run" ]]; then + u=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "$urus_run") + a=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "$axum_run") + ratio=$(awk -v u="$u" -v a="$a" 'BEGIN { if (a+0 > 0) printf "%.2f", (u+0)/(a+0); else print "n/a" }') + pass=$(awk -v r="$ratio" 'BEGIN { print (r != "n/a" && r+0 >= 0.5) ? "✅" : "❌" }') + printf '| %-8s | %8.0f | %8.0f | %9s | %-17s |\n' "$scn" "$u" "$a" "$ratio" "$pass" + else + printf '| %-8s | %8s | %8s | %9s | %-17s |\n' "$scn" "—" "—" "—" "—" + fi + done + echo + + # --- per-run detail links ---------------------------------------------- + echo "## Per-run artefacts" + echo + for r in "${succeeded[@]}"; do + set -- $r + s="$1"; sc="$2" + echo "- \`${s}-${sc}/\` — [result.json](${s}-${sc}/result.json), [measure.txt](${s}-${sc}/measure.txt), [server.log](${s}-${sc}/server.log)" + done + if [[ "${#failed[@]}" -gt 0 ]]; then + echo + echo "### Failed runs" + for r in "${failed[@]}"; do + set -- $r + s="$1"; sc="$2" + echo "- \`${s}-${sc}\` — see \`${s}-${sc}.stdout.log\`" + done + fi +} > "$SUMMARY" + +# --------------------------------------------------------------------------- +# Emit machine-readable index.json +# --------------------------------------------------------------------------- + +{ + echo "{" + echo " \"batch\": \"${BATCH}\"," + echo " \"elapsed_seconds\": ${ELAPSED}," + echo " \"runs\": [" + first=1 + for k in "${!by_run[@]}"; do + set -- $k + s="$1"; sc="$2" + rj="${by_run[$k]}" + [[ "$first" -eq 1 ]] || echo " ," + first=0 + echo " {" + echo " \"server\": \"${s}\"," + echo " \"scenario\": \"${sc}\"," + echo " \"result_path\": \"${s}-${sc}/result.json\"," + # Inline the result.json contents, indented; simplest is to just paste. + awk '/^\{/{p=1} p{print " " $0} /^\}/{p=0}' "$rj" \ + | sed '1d' | head -n -1 | sed 's/^/ "result": {/; t; s/^/ /' >/dev/null + # Simpler: just reference the file path. The result.json is already + # right there. We don't need to inline. + echo " }" + done + echo " ]" + echo "}" +} > "$INDEX" + +# --------------------------------------------------------------------------- +# Wrap up +# --------------------------------------------------------------------------- + +echo +echo "==========================================================" +echo "run_all: DONE" +echo " batch: ${BATCH}" +echo " elapsed: ${ELAPSED}s" +echo " successful: ${#succeeded[@]} / ${total}" +[[ "${#failed[@]}" -gt 0 ]] && echo " failed: ${failed[*]}" +echo " summary: ${SUMMARY}" +echo "==========================================================" +echo +cat "$SUMMARY" diff --git a/runner.sh b/runner.sh new file mode 100755 index 0000000..ba213c8 --- /dev/null +++ b/runner.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env bash +# urus-bench/runner.sh +# +# Runs one (server, scenario) measurement and saves all artefacts under +# results/${run_id}/. The intent is to make a single run trivially +# repeatable so the headline table in the bench-spec can be filled in +# row by row. +# +# Usage: +# ./runner.sh [run_id] +# +# server: +# urus-mem | urus-sqlite | axum-mem | axum-sqlite | cowboy-mem | cowboy-sqlite +# scenario: +# s1 | s2 | s3 | s4 +# run_id: +# optional; defaults to ${date}-${server}-${scenario} +# +# Requirements on the host: +# - taskset (util-linux) +# - wrk2 in PATH (https://github.com/giltene/wrk2) +# - wrk in PATH (saturation probe) +# - pidstat (sysstat) -- optional, CPU stats only +# - jq -- pretty-prints the result.json +# +# CPU pinning (defaults; override with SERVER_CPUS / LOADGEN_CPUS): +# server: taskset -c 0-7 +# loadgen: taskset -c 8-15 +# +# Probe / measurement times: 30s probe, 30s warm-up, 60s measured (per the +# bench-spec §1.5). Override with PROBE_SEC / WARMUP_SEC / MEASURE_SEC. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Args / config +# --------------------------------------------------------------------------- + +SERVER="${1:-}" +SCENARIO="${2:-}" +RUN_ID="${3:-$(date +%Y%m%d-%H%M%S)-${SERVER}-${SCENARIO}}" + +if [[ -z "$SERVER" || -z "$SCENARIO" ]]; then + echo "usage: $0 [run_id]" >&2 + echo " server: urus-mem | urus-sqlite | axum-mem | axum-sqlite | cowboy-mem | cowboy-sqlite" >&2 + echo " scenario: s1 | s2 | s3 | s4" >&2 + exit 2 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESULTS_DIR="${REPO_ROOT}/results/${RUN_ID}" +mkdir -p "$RESULTS_DIR" + +SERVER_CPUS="${SERVER_CPUS:-0-7}" +LOADGEN_CPUS="${LOADGEN_CPUS:-8-15}" +PROBE_SEC="${PROBE_SEC:-30}" +WARMUP_SEC="${WARMUP_SEC:-30}" +MEASURE_SEC="${MEASURE_SEC:-60}" +PORT="${PORT:-8080}" +HOST="${HOST:-127.0.0.1:${PORT}}" +BEARER="${BEARER:-test-token-aaaaaaaaaaaaaaaaaaaa}" +WRK_THREADS="${WRK_THREADS:-8}" +WRK_CONNS="${WRK_CONNS:-256}" +SAT_RATIO="${SAT_RATIO:-0.70}" # wrk2 target = saturation * SAT_RATIO + +# --------------------------------------------------------------------------- +# Pick server command + lua script +# --------------------------------------------------------------------------- + +case "$SERVER" in + urus-mem) + SERVER_CMD=("${REPO_ROOT}/target/release/urus-server" + "--addr=127.0.0.1:${PORT}" "--store=memory") ;; + urus-sqlite) + DB="/tmp/urus-bench-${RUN_ID}.sqlite"; rm -f "${DB}"* + SERVER_CMD=("${REPO_ROOT}/target/release/urus-server" + "--addr=127.0.0.1:${PORT}" "--store=sqlite" + "--db-path=${DB}") ;; + axum-mem) + SERVER_CMD=("${REPO_ROOT}/target/release/axum-server" + "--addr=127.0.0.1:${PORT}" "--store=memory") ;; + axum-sqlite) + DB="/tmp/axum-bench-${RUN_ID}.sqlite"; rm -f "${DB}"* + SERVER_CMD=("${REPO_ROOT}/target/release/axum-server" + "--addr=127.0.0.1:${PORT}" "--store=sqlite" + "--db-path=${DB}") ;; + cowboy-mem|cowboy-sqlite) + # cowboy-server is a placeholder in this checkout; wire when ready. + echo "runner: cowboy backends are not wired yet" >&2 + exit 3 ;; + *) + echo "runner: unknown server '${SERVER}'" >&2; exit 2 ;; +esac + +case "$SCENARIO" in + s1) LUA="${REPO_ROOT}/loadgen/s1_ping.lua" ;; + s2) LUA="${REPO_ROOT}/loadgen/s2_user_get.lua" ;; + s3) LUA="${REPO_ROOT}/loadgen/s3_mixed.lua" ;; + s4) LUA="${REPO_ROOT}/loadgen/s3_mixed.lua" ;; # same script, different store + *) echo "runner: unknown scenario '${SCENARIO}'" >&2; exit 2 ;; +esac + +# Compatibility check: s4 requires a sqlite-backed server. +if [[ "$SCENARIO" == "s4" && "$SERVER" != *-sqlite ]]; then + echo "runner: scenario s4 requires a *-sqlite server (got ${SERVER})" >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Tool checks +# --------------------------------------------------------------------------- + +need() { + command -v "$1" >/dev/null || { echo "runner: missing tool: $1" >&2; exit 4; } +} +need taskset +need wrk +need wrk2 + +HAS_PIDSTAT=0; command -v pidstat >/dev/null && HAS_PIDSTAT=1 +HAS_JQ=0; command -v jq >/dev/null && HAS_JQ=1 + +# --------------------------------------------------------------------------- +# Verify the server binary actually exists before launching. Without this +# check, a missing binary surfaces as "server did not come up" 10 seconds +# later, which is misleading. +# --------------------------------------------------------------------------- + +SERVER_BIN="${SERVER_CMD[0]}" +if [[ ! -x "$SERVER_BIN" ]]; then + echo "runner: server binary not found or not executable: ${SERVER_BIN}" >&2 + echo "runner: did you run \`cargo build --release\` in the workspace root?" >&2 + exit 5 +fi + +# --------------------------------------------------------------------------- +# Boot server (pinned), wait for it to listen +# --------------------------------------------------------------------------- + +SERVER_LOG="${RESULTS_DIR}/server.log" +echo "runner: launching server on cores ${SERVER_CPUS}: ${SERVER_CMD[*]}" +taskset -c "$SERVER_CPUS" "${SERVER_CMD[@]}" \ + > "$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +cleanup() { + if kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +# Wait up to 10s for the port to accept. +for _ in $(seq 1 100); do + if (echo > /dev/tcp/127.0.0.1/${PORT}) 2>/dev/null; then + break + fi + sleep 0.1 +done +if ! (echo > /dev/tcp/127.0.0.1/${PORT}) 2>/dev/null; then + echo "runner: server did not come up; see ${SERVER_LOG}" >&2 + exit 5 +fi + +# --------------------------------------------------------------------------- +# Wire-level smoke test — fail fast if the routes don't behave. +# --------------------------------------------------------------------------- + +smoke_curl() { + local expected="$1"; shift + local got + got=$(curl -s -o /dev/null -w "%{http_code}" "$@") + if [[ "$got" != "$expected" ]]; then + echo "runner: smoke fail: expected ${expected} got ${got} for: $*" >&2 + exit 6 + fi +} + +smoke_curl 200 "http://127.0.0.1:${PORT}/ping" +smoke_curl 200 -H "Authorization: Bearer ${BEARER}" \ + "http://127.0.0.1:${PORT}/api/v1/users" +smoke_curl 401 "http://127.0.0.1:${PORT}/api/v1/users" + +echo "runner: smoke OK" + +# --------------------------------------------------------------------------- +# Pre-populate store for s2/s3/s4 (s1 doesn't touch the store). +# --------------------------------------------------------------------------- +# +# The s2 / s3 / s4 Lua scripts pick random IDs in [1, 10000]. Against an +# empty store every GET-by-id is a 404 fast path on most stacks, which is +# *not* what we want to bench. Seed the store with PREPOP=10000 users +# (matching the ID range) so GETs hit the actual happy path. This is also +# why the bench-spec calls for warm-up: pre-population is part of that. +# +# For s1 (/ping), skip prepop entirely. + +PREPOP="${PREPOP:-10000}" +if [[ "$SCENARIO" != "s1" && "$PREPOP" -gt 0 ]]; then + echo "runner: pre-populating store with ${PREPOP} users" + TMP_REQUESTS=$(mktemp) + # awk is ~100x faster than a bash for-loop at producing this file. + awk -v n="$PREPOP" -v tok="$BEARER" -v port="$PORT" ' + BEGIN { + for (i = 1; i <= n; i++) { + printf "url = http://127.0.0.1:%s/api/v1/users\n", port + printf "request = POST\n" + printf "header = \"Authorization: Bearer %s\"\n", tok + printf "header = \"Content-Type: application/json\"\n" + printf "data = \"{\\\"name\\\":\\\"u%d\\\",\\\"email\\\":\\\"u%d@x\\\"}\"\n", i, i + if (i < n) printf "next\n" + } + }' > "$TMP_REQUESTS" + # Send all curl output (response bodies + status) to the runner log. + # -K does not honor a global -o; the cleanest suppression is shell-level. + curl -s -K "$TMP_REQUESTS" >> "$SERVER_LOG" 2>&1 || true + rm -f "$TMP_REQUESTS" + # Verify list has at least one entry; print count for visibility. + N=$(curl -s -H "Authorization: Bearer ${BEARER}" \ + "http://127.0.0.1:${PORT}/api/v1/users" | tr ',' '\n' | grep -c '"id"' || echo 0) + echo "runner: store now reports ${N} users (list cap is 100)" +fi + + +# --------------------------------------------------------------------------- +# pidstat sampler (background) — CPU% and RSS at 1Hz +# --------------------------------------------------------------------------- + +STATS_LOG="${RESULTS_DIR}/proc-stats.tsv" +echo -e "ts\tcpu_pct\trss_kb" > "$STATS_LOG" +( + while kill -0 "$SERVER_PID" 2>/dev/null; do + ts=$(date +%s) + rss=$(awk '/^VmRSS:/ {print $2}' "/proc/${SERVER_PID}/status" 2>/dev/null || echo 0) + if [[ "$HAS_PIDSTAT" == "1" ]]; then + cpu=$(pidstat -p "$SERVER_PID" 1 1 2>/dev/null \ + | awk '/Average:/ {print $8}' | head -1) + else + cpu="" + fi + echo -e "${ts}\t${cpu}\t${rss}" >> "$STATS_LOG" + sleep 1 + done +) & +STATS_PID=$! + +# --------------------------------------------------------------------------- +# Phase 1: saturation probe with wrk (closed-loop peak) +# --------------------------------------------------------------------------- + +echo "runner: probe (${PROBE_SEC}s closed-loop, ${WRK_CONNS} conns)" +PROBE_OUT="${RESULTS_DIR}/probe.txt" +taskset -c "$LOADGEN_CPUS" \ + env HOST="$HOST" BEARER="$BEARER" \ + wrk -t"$WRK_THREADS" -c"$WRK_CONNS" -d"${PROBE_SEC}s" --latency \ + -s "$LUA" "http://${HOST}" \ + > "$PROBE_OUT" 2>&1 || true + +PROBE_RPS=$(awk '/Requests\/sec:/ {print $2}' "$PROBE_OUT" | head -1) +if [[ -z "$PROBE_RPS" ]]; then + echo "runner: probe did not report Requests/sec; see ${PROBE_OUT}" >&2 + exit 7 +fi + +# floor(PROBE_RPS * SAT_RATIO) as an integer (wrk2 requires int -R) +TARGET_RPS=$(awk -v p="$PROBE_RPS" -v r="$SAT_RATIO" \ + 'BEGIN { printf("%d", int(p * r)) }') +echo "runner: probe RPS=${PROBE_RPS}, target (sustained)=${TARGET_RPS}" + +# --------------------------------------------------------------------------- +# Phase 2: warm-up at target rate (discarded) +# --------------------------------------------------------------------------- + +echo "runner: warm-up (${WARMUP_SEC}s at -R${TARGET_RPS})" +taskset -c "$LOADGEN_CPUS" \ + env HOST="$HOST" BEARER="$BEARER" \ + wrk2 -t"$WRK_THREADS" -c"$WRK_CONNS" -d"${WARMUP_SEC}s" \ + -R"$TARGET_RPS" -s "$LUA" "http://${HOST}" \ + > "${RESULTS_DIR}/warmup.txt" 2>&1 || true + +# --------------------------------------------------------------------------- +# Phase 3: measured run with wrk2 (latency-honest) +# --------------------------------------------------------------------------- + +echo "runner: measure (${MEASURE_SEC}s at -R${TARGET_RPS})" +MEASURE_OUT="${RESULTS_DIR}/measure.txt" +taskset -c "$LOADGEN_CPUS" \ + env HOST="$HOST" BEARER="$BEARER" \ + wrk2 -t"$WRK_THREADS" -c"$WRK_CONNS" -d"${MEASURE_SEC}s" \ + -R"$TARGET_RPS" --latency \ + -s "$LUA" "http://${HOST}" \ + > "$MEASURE_OUT" 2>&1 + +# --------------------------------------------------------------------------- +# Phase 4: parse + write result.json +# --------------------------------------------------------------------------- + +# wrk2 output format (excerpt; this regex stack is fragile but works on +# upstream wrk2's current build): +# +# Requests/sec: 149999.74 +# Latency Distribution (HdrHistogram - Recorded Latency) +# 50.000% 0.50ms +# 75.000% 0.61ms +# ... +# 99.000% 1.83ms +# 99.900% 4.21ms +# 99.990% 13.50ms +# 99.999% 24.50ms +# 100.000% 31.50ms + +extract() { + local label="$1" # e.g. "50.000%" + awk -v lbl="$label" '$1 == lbl { print $2; exit }' "$MEASURE_OUT" +} + +SUSTAINED_RPS=$(awk '/Requests\/sec:/ {print $2}' "$MEASURE_OUT" | head -1) +P50=$(extract "50.000%") +P90=$(extract "90.000%") +P99=$(extract "99.000%") +P999=$(extract "99.900%") +MAX=$(extract "100.000%") +NON_2XX=$(awk '/Non-2xx or 3xx responses:/ {print $5}' "$MEASURE_OUT" | head -1) +# Coerce to a JSON integer; empty (line absent → no errors) or non-numeric +# becomes 0. +if ! [[ "$NON_2XX" =~ ^[0-9]+$ ]]; then NON_2XX=0; fi + +# Best-effort RSS / CPU summary from the sampler +MEAN_CPU=$(awk -F'\t' 'NR>1 && $2 != "" { s+=$2; n++ } END { if (n>0) printf("%.1f", s/n); else print "" }' "$STATS_LOG") +MAX_RSS=$(awk -F'\t' 'NR>1 { if ($3+0 > m) m=$3 } END { print m+0 }' "$STATS_LOG") + +cat > "${RESULTS_DIR}/result.json" </dev/null || true +wait "$STATS_PID" 2>/dev/null || true + +echo +echo "runner: DONE -> ${RESULTS_DIR}" +if [[ "$HAS_JQ" == "1" ]]; then + jq . "${RESULTS_DIR}/result.json" +else + cat "${RESULTS_DIR}/result.json" +fi diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2f4a1b6 --- /dev/null +++ b/shell.nix @@ -0,0 +1,66 @@ +# urus-bench shell.nix +# +# Assumes you already have a working rust toolchain (cargo, rustc) and +# elixir/erlang in your environment. This shell adds: +# +# - the load generators (wrk, wrk2) +# - process introspection (pidstat, taskset) +# - small conveniences (jq, sqlite CLI for poking at the WAL'd DBs) +# - C toolchain bits so `rusqlite` (bundled SQLite) and `sqlx` compile +# +# Drop into the workspace root and run `nix-shell`. Then: +# cargo build --release +# ./runner.sh urus-mem s1 +# +# For cowboy-server you'll use your existing rebar3/elixir setup; this +# shell deliberately doesn't pin a beam toolchain since you have one. + +{ pkgs ? import {} }: + +pkgs.mkShell { + name = "urus-bench"; + + # buildInputs vs nativeBuildInputs: for a developer shell the + # distinction barely matters; either works. Keeping everything in + # `packages` since that's the recommended modern form. + packages = with pkgs; [ + # Load generators (the whole point of this shell). + wrk # closed-loop, used as the saturation probe + wrk2 # constant-rate, latency-honest measurement + + # Runner deps. + sysstat # provides pidstat for CPU sampling + util-linux # provides taskset for CPU pinning + jq # pretty-prints result.json (runner falls back without it) + curl # the prepopulate step uses curl --config (-K) + gawk # explicit; busybox awk lacks some printf bits we use + + # SQLite tooling — handy for inspecting WAL'd DBs between runs. + # rusqlite/sqlx bring their own embedded sqlite so this is *not* a + # build dep, just a CLI for poking around. + sqlite + + # C toolchain — rusqlite's `bundled` feature compiles sqlite from + # source, and sqlx's macros also need a working cc. Without these + # the rust build fails on a fresh nix shell. + pkg-config + gcc + ]; + + # Environment hints. Nothing strictly required, but these defaults + # match what runner.sh already expects when invoked without env + # overrides, so a fresh shell `just works` for `./runner.sh ...`. + BEARER = "test-token-aaaaaaaaaaaaaaaaaaaa"; + + shellHook = '' + echo "urus-bench shell:" + echo " wrk $(wrk --version 2>&1 | head -1 || echo missing)" + echo " wrk2 $(wrk2 --version 2>&1 | head -1 || echo missing)" + echo " pidstat $(command -v pidstat || echo missing)" + echo " taskset $(command -v taskset || echo missing)" + echo " cargo $(command -v cargo || echo 'NOT FOUND — expected on host')" + echo + echo "Build: cargo build --release" + echo "Run: ./runner.sh urus-mem s1" + ''; +} diff --git a/urus-bench-spec.md b/urus-bench-spec.md new file mode 100644 index 0000000..df7950f --- /dev/null +++ b/urus-bench-spec.md @@ -0,0 +1,462 @@ +# Urus v1 Benchmark Plan + +> Goal: produce defensible HTTP/1.1 numbers for **urus v0.1**, framed by +> **hyper** (Rust async reference) above and **cowboy** (BEAM reference) +> below. Per the urus spec: target is **within 2x of hyper**; significantly +> outperform Cowboy 2 on HTTP/1.1. + +The benchmark is layered, smallest-first, so we can see *what* costs what: +the plug indirection, the connection-actor context switch, the pipeline +middleware stack, and finally the DB layer. + +--- + +## 1. Design considerations (read first) + +### 1.1 Don't bench the DB + +A "realistic" load that hits a DB on every request will almost certainly +bench the DB instead of the HTTP stack. SQLite single-writer contention is +the most likely culprit. The plan is: + +- Tier the scenarios. The cheapest scenario does **no DB work** so we get a + clean read on the HTTP stack itself. Each subsequent tier adds one source + of work and is compared against the previous tier — the *delta* is the + cost of that layer. +- Instrument the handler with a histogram of "handler-inside time" + (microseconds spent between the start of the handler and the moment it + returns the `Conn`). If at high concurrency the handler-inside time + dominates request latency, we're benching the DB / the in-memory actor / + the auth check, not the framework. Report this metric *next to* RPS. +- For the DB tier, use **SQLite in WAL mode**, with one dedicated writer + actor and N reader connections (or a single-conn reader pool with `BEGIN + IMMEDIATE` semantics). This matches urus's actor-style data ownership and + is fair to all three frameworks. +- Cross-check by also running each scenario with the in-memory store from + `examples/crud.rs`. If urus's in-memory and SQLite numbers are close at + low write ratios, the DB is not the bottleneck; if SQLite is much lower, + it is. + +### 1.2 wrk2, not wrk + +`wrk` (Glenn's original) is closed-loop: it sends a new request only after +the previous one returns. Tail latency in that model is meaningless +("coordinated omission" — the load generator stalls when the server stalls, +so it can't *measure* the stall). We use **wrk2** with a fixed rate `-R`. + +For a smoke-test top-end RPS we'll also run plain `wrk` at maximum offered +load to find each server's *saturation point*. That number is then used to +pick the target rate for wrk2 — typically 60–80% of saturation, where tail +latency is the actual measurement of interest. + +Cross-checks: `oha` (open-loop, HDR-histogram output) and `rewrk`. + +### 1.3 Equivalent stacks across servers + +The bench is unfair unless the middleware stack is materially the same on +all three. Spec: + +- Request logger (writes to a bounded ring buffer in memory; **no stdout**, + which would skew everything). +- Request ID middleware (generates a short ID, sets a response header). +- Bearer token auth (constant-time compare against a fixed valid token). +- JSON request/response on the write paths. +- Router with at least one literal route, one parameterised route, and one + POST route. + +Cowboy's idiomatic equivalent is `cowboy_router` + handler modules + an +`onrequest` hook for auth. Hyper's idiomatic equivalent is **axum**: it +exposes the closest analogue to urus's pipeline (`tower::Service` + layers ++ Router). We bench against axum, and note that "raw hyper" would be 10–20% +faster but is not a fair comparison to a routing+middleware stack. + +### 1.4 Two-process discipline + +Server and load generator run on the **same host but with pinned, disjoint +CPU sets** to avoid each starving the other. On a 16-core box: + +- Server: `taskset -c 0-7` +- Loadgen: `taskset -c 8-15` + +This matters more than it sounds: with both on all cores, you measure the +loadgen's scheduling as much as the server's. + +### 1.5 Warm-up + +Each run is `30s warm-up + 60s measured`. The warm-up is necessary because: +- TCP window scaling needs time to ramp. +- Page-fault costs for the read buffers happen once. +- BEAM in particular has a JIT (BeamAsm) that benefits from warm-up. + +Save raw HDR histograms (wrk2 emits one); aggregate p50/p95/p99/p99.9/max. + +### 1.6 What we don't measure (yet) + +- TLS: deferred per urus spec (v2+). +- HTTP/2: deferred per urus spec (v2). +- WebSocket: deferred (v3). +- Cold-start / spawn cost: orthogonal to the steady-state question this + bench answers. + +--- + +## 2. Scenarios + +Four scenarios, ordered cheapest to most realistic. Each is run against +each framework. Each measures the **delta** over the previous one. + +### S1: `GET /ping` — naked hello world + +- Pipeline: a single handler. No logger, no auth, no router. +- Response: `200 OK`, body `pong` (4 bytes), `content-type: text/plain`. +- Measures: raw protocol throughput. The floor on framework overhead. +- Connection: keep-alive (default in HTTP/1.1). + +This is the number to compare against hyper's `hello` benchmark. The urus +spec target is "within 2x". The plug pipeline isn't really exercised here — +that's deliberate. S1 isolates the connection actor + parser + serialiser. + +### S2: `GET /api/v1/users/:id` — router + middleware, no DB + +- Pipeline: `logger → request_id → auth → router`. +- Route: `GET /api/v1/users/:id` returns a hard-coded `User` struct + serialised to JSON. The `:id` is echoed into the response so the + serialiser cannot const-fold it away. +- Bearer token check: a known good token sent in the `Authorization` + header. Mismatch → 401 + halt. +- Measures: the cost of the pipeline (4 plug hops) and the router lookup. + This is where urus's spec budget ("one vtable dispatch per plug per + request") gets tested. +- Connection: keep-alive. + +Expected: lower RPS than S1 by a measurable but non-catastrophic margin (a +few % to maybe 20%). If S2 is half of S1, something is allocating per +request. + +### S3: `POST /api/v1/users` + `GET /api/v1/users/:id` — in-memory store + +- Pipeline: same as S2. +- Routes: + - `POST /api/v1/users` with a small JSON body (`{"name":"alice","email":"a@x"}`). + - `GET /api/v1/users/:id`. + - `GET /api/v1/users` (list, capped at 100). +- Store: in-memory actor (urus: the actor from `examples/crud.rs`; hyper: + `tokio::sync::Mutex`; cowboy: a `gen_server`). No persistence. +- Traffic mix: **80% GET single, 15% GET list, 5% POST**. This is the + typical web-app shape and exposes the message-passing path on urus + without making writes the bottleneck. +- Measures: actor channel round-trip vs. mutex contention. Urus does a + channel send-recv to its store; hyper holds a brief mutex. The bench + shows the cost of message passing vs. the cost of contention as + concurrency rises. +- Connection: keep-alive. + +### S4: Same as S3, with SQLite (WAL) + +- Same routes and mix as S3. +- Backing store: + - urus: one writer actor owning a write connection; reads go through a + pool of N=`min(cpus, 4)` read-only connections (each held by its own + actor, requested via a worker pool). All `BEGIN IMMEDIATE`. + - hyper/axum: `sqlx::SqlitePool` with WAL + busy_timeout. + - cowboy: `esqlite` with the same WAL config; pool via `poolboy`. +- Measures: realism. We expect everyone to be much lower here. If the + three framework numbers *converge*, the DB is the bottleneck and the + framework comparison can't be made from this scenario alone — that's + the answer S4 might give us, and it's still useful information. + +In **all four scenarios** the handler instruments +`handler_microseconds_histogram` so we can detect DB domination. + +--- + +## 3. The middleware stack (spec for all three implementations) + +Each implementation MUST expose these middlewares with materially identical +behaviour. The wire-level outputs (status codes, body shapes, headers) MUST +match — verified by a small `curl`-based smoke test before each bench run. + +### 3.1 Logger + +- Captures: `method`, `path`, `status`, `elapsed_us`. +- Writes into a **bounded ring buffer in memory** (e.g. `crossbeam::ArrayQueue` for Rust, an ETS table for cowboy with capped size). +- Does **not** touch stdout, stderr, or the filesystem during the run. +- The buffer is drained on shutdown to a file for inspection. + +### 3.2 Request ID + +- 12-byte random ID, base32-encoded. +- Set as response header `x-request-id`. +- Generated from a thread-local fast RNG (avoid the global `rand::thread_rng()` mutex on Rust; use SplitMix64 seeded once per actor/task). + +### 3.3 Auth + +- Reads `Authorization: Bearer ` from request headers. +- Compares against a fixed token (configured at startup) using + **constant-time compare** (`subtle::ConstantTimeEq` on Rust; + `crypto:hash`-based on BEAM is fine — the constant-time property is + pedagogical here, not a security claim). +- On mismatch: `401`, body `{"error":"unauthorized"}`, halt the pipeline. +- The bench load generator always sends the valid token (we are not + benching the failure path). + +### 3.4 Router + +- Methods: GET, POST, PUT, DELETE. +- Patterns: `/api/v1/users`, `/api/v1/users/:id`, `/ping`. +- 404 on no match; 405 on method-mismatch (urus already does this). + +### 3.5 JSON + +- `serde_json` for Rust, `jsx` or `jsone` for BEAM. +- Bodies are small: well under 1 KiB for the user payload. + +--- + +## 4. Queries (load-gen scripts) + +`wrk2` takes a Lua script. Below are the four scripts. They share a single +common header script (`common.lua`) for token + content-type. + +### `common.lua` + +```lua +-- Shared header config; included by every script below. +local M = {} +M.token = os.getenv("BEARER") or "test-token-aaaaaaaaaaaaaaaaaaaa" +M.headers = { + ["Authorization"] = "Bearer " .. M.token, + ["Connection"] = "keep-alive", + ["Host"] = os.getenv("HOST") or "127.0.0.1:8080", +} +return M +``` + +### `s1_ping.lua` — S1 ping, no auth header (it's a naked endpoint) + +```lua +wrk.method = "GET" +wrk.path = "/ping" +wrk.headers["Connection"] = "keep-alive" +``` + +Invocation: + +```sh +# Saturation probe (closed-loop wrk): +wrk -t8 -c256 -d60s --latency http://127.0.0.1:8080/ping + +# Latency-honest measurement (wrk2, target rate from probe): +wrk2 -t8 -c256 -d60s -R200000 --latency -s s1_ping.lua \ + http://127.0.0.1:8080 +``` + +### `s2_user_get.lua` — single-route GET with middleware + +```lua +local common = require("common") +wrk.method = "GET" +wrk.headers = common.headers + +-- Spread :id across 10_000 values so per-id caches can't trivially win. +math.randomseed(os.time() + os.getpid()) +request = function() + local id = math.random(1, 10000) + return wrk.format(nil, "/api/v1/users/" .. id) +end +``` + +Invocation: + +```sh +wrk2 -t8 -c256 -d60s -R150000 --latency -s s2_user_get.lua \ + http://127.0.0.1:8080 +``` + +### `s3_mixed.lua` — 80% GET-one / 15% GET-list / 5% POST + +```lua +local common = require("common") +wrk.headers = common.headers +wrk.headers["Content-Type"] = "application/json" + +math.randomseed(os.time() + os.getpid()) +local body_pool = {} +for i = 1, 64 do + body_pool[i] = string.format( + [[{"name":"user%d","email":"u%d@example.test"}]], i, i) +end + +request = function() + local r = math.random() + if r < 0.80 then + return wrk.format("GET", "/api/v1/users/" .. math.random(1, 10000)) + elseif r < 0.95 then + return wrk.format("GET", "/api/v1/users") + else + local body = body_pool[math.random(1, #body_pool)] + return wrk.format("POST", "/api/v1/users", nil, body) + end +end +``` + +Invocation: same shape as s2, lower `-R` (writes are slower). + +```sh +wrk2 -t8 -c512 -d60s -R80000 --latency -s s3_mixed.lua \ + http://127.0.0.1:8080 +``` + +### `s4_mixed_sqlite.lua` + +Identical to `s3_mixed.lua`. Only the server's backing store differs. + +```sh +wrk2 -t8 -c512 -d60s -R20000 --latency -s s4_mixed_sqlite.lua \ + http://127.0.0.1:8080 +``` + +(`-R20000` is a guess; the real number comes from a `wrk` probe of each +framework first.) + +--- + +## 5. Implementation plan + +Three sibling crates / projects under a `urus-bench/` workspace, each +exposing the same routes and middleware: + +``` +urus-bench/ +├── urus-server/ # Rust, urus +├── axum-server/ # Rust, hyper via axum +└── cowboy-server/ # Erlang/OTP, cowboy 2 +``` + +Plus: + +``` +urus-bench/ +├── loadgen/ # wrk2 scripts (common.lua, s1..s4 above) +├── runner.sh # orchestrates pin-cpu, warm-up, measurement, save HDR +└── results/ # one subdir per run: ${date}-${server}-${scenario}/ +``` + +The runner: + +1. Boots the target server pinned to cores 0-7. +2. `curl`-smoke-tests every route. +3. Probes saturation with `wrk` at a high `-c`. +4. Reads back the saturation RPS and chooses 70% of it for wrk2. +5. Runs wrk2 with HDR output (`--latency`), captures stdout + HDR file. +6. Greps server process for RSS (`/proc/$pid/status`) and CPU (`pidstat -p $pid 1`) during the run. +7. Saves everything under `results/${run_id}/`. +8. Kills server, sleeps 5s, moves on. + +### 5.1 urus-server + +Port `examples/crud.rs` with: + +- Routes adjusted to `/api/v1/users{,/:id}` and `/ping`. +- Logger replaced with a ring-buffer-backed plug. +- New `request_id` and `auth` plugs. +- Switchable backing store: `--store=memory` (the existing actor) or + `--store=sqlite` (a new actor wrapping `rusqlite` in WAL). + +For SQLite on urus: spawn one writer actor + 4 reader actors, each owning +its own `rusqlite::Connection`. Handlers route reads to a free reader via +a worker-pool channel, writes to the writer. Pool selection is FIFO over a +single shared `Receiver` — readers race to receive, which +is exactly what smarm's MPSC gives for free. + +### 5.2 axum-server + +Standard axum + tokio multi-thread. Same routes. Layers: `Logger`, +`SetRequestIdLayer`, `RequireAuthorizationLayer`. State: `sqlx::SqlitePool` +for S4, `Arc>` for S3. + +### 5.3 cowboy-server + +Standard cowboy 2 with `cowboy_router`. Handlers as `cowboy_handler` +modules. Auth as an `onrequest`-style middleware. Store: a `gen_server` +for S3, `esqlite` + `poolboy` for S4. + +Build with `rebar3`, run with `+S 8 +sbt db` (8 schedulers, scheduler-thread +binding) under the same `taskset -c 0-7`. + +--- + +## 6. Metrics and reporting + +Per (server × scenario) run, capture and report: + +| Metric | How | +|-----------------------------|------------------------------------------------| +| RPS (saturation, `wrk`) | `wrk -c256 -d60s` — closed-loop peak | +| RPS (sustained, `wrk2`) | `wrk2 -R$target -d60s` at 70% of saturation | +| p50 / p95 / p99 / p99.9 / max latency | HDR histogram from wrk2 | +| Server CPU% | `pidstat -p $pid 1 60` → mean over run | +| Server RSS | `/proc/$pid/status` `VmRSS` sampled at 1 Hz | +| Handler-inside p99 (µs) | server-side histogram, emitted to ring buffer | +| Dropped/errored requests | wrk2's "Non-2xx or 3xx responses" | + +The headline table: + +| | urus | axum | cowboy | +|--------------------|------|------|--------| +| S1 RPS (sat) | | | | +| S1 p99 @ 70% | | | | +| S2 RPS (sat) | | | | +| S2 p99 @ 70% | | | | +| S3 RPS (sat) | | | | +| S3 p99 @ 70% | | | | +| S4 RPS (sat) | | | | +| S4 p99 @ 70% | | | | +| urus / axum (S1) | — | | — | +| urus / axum (S2) | — | | — | + +Pass/fail per the urus spec: + +- **S1**: urus RPS ≥ 50% of axum RPS (within 2x). +- **S2**: urus RPS ≥ 50% of axum RPS. +- **All scenarios**: urus RPS > cowboy RPS by a clear margin (native + expectation). +- **All scenarios**: urus p99 < 2× axum p99 at the same offered rate. +- Handler-inside p99 ≪ end-to-end p99 in S1/S2 (proves we're benching the + framework, not the handler). + +--- + +## 7. Order of operations (suggested) + +1. Build `urus-server` with S1 (`/ping`) only. Run S1 against it. Make + sure the saturation number is plausible (tens to hundreds of kRPS on + a decent box). +2. Build `axum-server` with S1. Run S1. Sanity-check the ratio against + public hyper hello-world numbers (axum should land near hyper). +3. Add the middleware stack to both, run S2, capture deltas. +4. Add the in-memory store, run S3. +5. Bring up `cowboy-server` and bring all three up to S3. +6. Add SQLite to all three; run S4. +7. Write the headline table; produce HDR plots from the saved files + (gnuplot or a tiny Python script — wrk2 ships an HDR-plot helper). + +If at any point a tier's results are dominated by a non-framework cost +(handler-inside p99 ≥ ~50% of end-to-end p99), stop and instrument before +moving on. That's the whole point of tiering. + +--- + +## 8. Open questions deliberately left for later + +- Whether to also bench under `tc`-induced packet loss / RTT. The current + setup uses loopback, which is a generous environment. A loopback win is + necessary but not sufficient for "fast on the real network". +- Whether to bench HTTP/1.1 pipelining (multiple in-flight requests per + connection without waiting). urus claims it's a non-goal-for-now, and + axum/hyper don't pipeline either, so the comparison is moot — but it's + worth a note. +- Whether to add a "slow handler" scenario (one route that sleeps 50ms) + to demonstrate urus's blocking-handler-without-thread-pool claim. That's + more of a *qualitative* demo than a throughput bench, but it's the kind + of thing reviewers ask about. diff --git a/urus-server/Cargo.toml b/urus-server/Cargo.toml new file mode 100644 index 0000000..d091f4e --- /dev/null +++ b/urus-server/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "urus-server" +version = "0.1.0" +edition = "2021" +rust-version = "1.95" + +[dependencies] +# Path deps point at the user's local checkouts; adjust if your tree +# differs. urus's own Cargo.toml has `smarm = { path = "../smarm" }`, so +# cargo resolves smarm transitively as long as it's a sibling of urus. +urus = { path = "../../urus" } +smarm = { path = "../../smarm" } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Constant-time compare for the bearer-token check. Tiny dep, no transitive +# bloat, and the right thing to use. +subtle = "2" + +# In-memory ring buffer for the logger plug. Bounded MPSC over a fixed +# array — no allocation on the hot path. +crossbeam-queue = "0.3" + +# Embedded SQLite. The `bundled` feature compiles SQLite from source so we +# don't depend on the system library — important for reproducibility on +# build hosts that may or may not have libsqlite3-dev. +rusqlite = { version = "0.32", features = ["bundled"] } + +# Tiny argv parser so we don't pull in clap for a 3-flag CLI. +# We hand-roll it instead. No dep here. + +[[bin]] +name = "urus-server" +path = "src/main.rs" diff --git a/urus-server/src/handlers.rs b/urus-server/src/handlers.rs new file mode 100644 index 0000000..4d454b1 --- /dev/null +++ b/urus-server/src/handlers.rs @@ -0,0 +1,192 @@ +//! Request handlers and the AppState that holds the store-handle factory. +//! +//! The trick: smarm requires `spawn` to be called from inside an actor. +//! The bootstrap thread that calls `serve_with` is NOT an actor — it's +//! the user's main(). So we can't spawn the store actor at startup; we +//! defer to the first request that asks for it. That request runs inside +//! a connection actor, which is a valid place to call spawn. +//! +//! AppState holds: +//! - a "store_init" factory that lazily spawns the store on first use +//! and caches the resulting StoreHandle +//! - the shared log ring (not yet used here; handlers don't log +//! directly, the logger plug does) +//! +//! Concurrency: the first connection actor to ask wins the init race; +//! the rest see the already-initialised handle. We use OnceLock for this +//! — std primitive, no extra dep. + +use std::sync::{Arc, OnceLock}; + +use smarm::channel; +use urus::{Conn, Next, Plug, Router}; + +use crate::middleware::LogRing; +use crate::store::{ReadReq, StoreHandle, WriteReq}; + +// --------------------------------------------------------------------------- +// AppState +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub struct AppState { + inner: Arc, +} + +struct AppStateInner { + store_handle: OnceLock, + store_init: Arc StoreHandle + Send + Sync + 'static>, + // Kept for parity with the spec even though handlers don't read it. + #[allow(dead_code)] + log_ring: LogRing, +} + +impl AppState { + pub fn new( + store_init: Arc StoreHandle + Send + Sync + 'static>, + log_ring: LogRing, + ) -> Self { + Self { + inner: Arc::new(AppStateInner { + store_handle: OnceLock::new(), + store_init, + log_ring, + }), + } + } + + /// Lazy store accessor. Safe to call from inside any connection + /// actor; safe to call concurrently — `OnceLock::get_or_init` + /// guarantees the init closure runs exactly once. + pub fn store(&self) -> &StoreHandle { + self.inner.store_handle.get_or_init(|| (self.inner.store_init)()) + } +} + +// --------------------------------------------------------------------------- +// Tiny helper for writing JSON responses +// --------------------------------------------------------------------------- + +fn json(conn: Conn, status: u16, body: Vec) -> Conn { + conn.put_status(status) + .put_header("content-type", "application/json") + .put_body(body) +} + +fn text(conn: Conn, status: u16, body: &'static str) -> Conn { + conn.put_status(status) + .put_header("content-type", "text/plain; charset=utf-8") + .put_body(body) +} + +fn parse_id(s: &str) -> Option { s.parse().ok() } + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- +// +// All handlers close over a clone of AppState. Each handler closure is a +// `Fn(Conn, Next) -> Conn`, which is exactly the Plug shape urus's +// blanket impl picks up. + +fn build_ping_handler() -> impl Plug { + // /ping is a literal endpoint: status 200, body "pong". No work. + move |conn: Conn, _next: Next| text(conn, 200, "pong") +} + +fn build_list_handler(state: AppState) -> impl Plug { + move |conn: Conn, _next: Next| { + let (tx, rx) = channel::<(u16, Vec)>(); + if state.store().reads().send(ReadReq::List { reply: tx }).is_err() { + return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()); + } + match rx.recv() { + Ok((s, b)) => json(conn, s, b), + Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()), + } + } +} + +fn build_get_one_handler(state: AppState) -> impl Plug { + move |conn: Conn, _next: Next| { + let id = match conn.params.get("id").and_then(parse_id) { + Some(id) => id, + None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()), + }; + let (tx, rx) = channel::<(u16, Vec)>(); + if state.store().reads().send(ReadReq::Get { id, reply: tx }).is_err() { + return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()); + } + match rx.recv() { + Ok((s, b)) => json(conn, s, b), + Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()), + } + } +} + +fn build_create_handler(state: AppState) -> impl Plug { + move |conn: Conn, _next: Next| { + // Take the body out of the request side. `Body::into_bytes` + // would need to consume `conn`, which we still need; clone the + // bytes instead. (For a 100-byte JSON object this is fine; for + // a large upload we'd want to redesign.) + let body = conn.body.as_bytes().to_vec(); + let (tx, rx) = channel::<(u16, Vec)>(); + if state.store().writes().send(WriteReq::Create { body, reply: tx }).is_err() { + return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()); + } + match rx.recv() { + Ok((s, b)) => json(conn, s, b), + Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()), + } + } +} + +fn build_update_handler(state: AppState) -> impl Plug { + move |conn: Conn, _next: Next| { + let id = match conn.params.get("id").and_then(parse_id) { + Some(id) => id, + None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()), + }; + let body = conn.body.as_bytes().to_vec(); + let (tx, rx) = channel::<(u16, Vec)>(); + if state.store().writes().send(WriteReq::Update { id, body, reply: tx }).is_err() { + return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()); + } + match rx.recv() { + Ok((s, b)) => json(conn, s, b), + Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()), + } + } +} + +fn build_delete_handler(state: AppState) -> impl Plug { + move |conn: Conn, _next: Next| { + let id = match conn.params.get("id").and_then(parse_id) { + Some(id) => id, + None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()), + }; + let (tx, rx) = channel::<(u16, Vec)>(); + if state.store().writes().send(WriteReq::Delete { id, reply: tx }).is_err() { + return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()); + } + match rx.recv() { + Ok((s, b)) => json(conn, s, b), + Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()), + } + } +} + +// --------------------------------------------------------------------------- +// Router assembly +// --------------------------------------------------------------------------- + +pub fn build_router(state: AppState) -> Router { + Router::new() + .get( "/ping", build_ping_handler()) + .get( "/api/v1/users", build_list_handler(state.clone())) + .post( "/api/v1/users", build_create_handler(state.clone())) + .get( "/api/v1/users/:id", build_get_one_handler(state.clone())) + .put( "/api/v1/users/:id", build_update_handler(state.clone())) + .delete("/api/v1/users/:id", build_delete_handler(state)) +} diff --git a/urus-server/src/main.rs b/urus-server/src/main.rs new file mode 100644 index 0000000..759c4f6 --- /dev/null +++ b/urus-server/src/main.rs @@ -0,0 +1,194 @@ +//! urus-server — the urus side of the urus / axum / cowboy benchmark. +//! +//! Exposes the routes from `urus-bench-spec.md` §3 with the middleware +//! stack from §3.1–§3.4. Backing store is switchable at startup: +//! +//! --store=memory in-memory actor (S1, S2, S3) +//! --store=sqlite single-writer + reader-pool over rusqlite (S4) +//! +//! Other flags: +//! --addr=HOST:PORT default 127.0.0.1:8080 +//! --token=TOKEN default "test-token-aaaaaaaaaaaaaaaaaaaa" +//! --db-path=PATH SQLite file path (sqlite store only) +//! --no-auth skip the auth plug (used for S1 /ping check) +//! +//! The `/ping` route is mounted *outside* the auth plug so the S1 scenario +//! does not pay an auth cost it doesn't need. S2/S3/S4 routes are inside it. + +mod handlers; +mod middleware; +mod store; + +use std::process::ExitCode; +use std::sync::Arc; + +use urus::{serve_with, Config, Conn, Next, Pipeline}; + +use handlers::AppState; +use middleware::{auth_plug, logger_plug, request_id_plug, LogRing}; +use store::{spawn_memory_store, spawn_sqlite_store, StoreHandle}; + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct Cli { + addr: String, + store: StoreKind, + token: String, + db_path: String, + no_auth: bool, +} + +#[derive(Clone, Copy, Debug)] +enum StoreKind { Memory, Sqlite } + +impl Cli { + fn parse() -> Result { + // Hand-rolled because clap would dominate compile time for three + // flags. Each arg is `--name=value` or `--name` (no positionals). + let mut cli = Cli { + addr: "127.0.0.1:8080".into(), + store: StoreKind::Memory, + token: "test-token-aaaaaaaaaaaaaaaaaaaa".into(), + db_path: "/tmp/urus-bench.sqlite".into(), + no_auth: false, + }; + for arg in std::env::args().skip(1) { + let (k, v) = match arg.split_once('=') { + Some((k, v)) => (k, Some(v.to_string())), + None => (arg.as_str(), None), + }; + match (k, v) { + ("--addr", Some(v)) => cli.addr = v, + ("--token", Some(v)) => cli.token = v, + ("--db-path", Some(v)) => cli.db_path = v, + ("--store", Some(v)) => cli.store = match v.as_str() { + "memory" => StoreKind::Memory, + "sqlite" => StoreKind::Sqlite, + other => return Err(format!("unknown store: {other}")), + }, + ("--no-auth", None) => cli.no_auth = true, + ("--help" | "-h", _) => { + print_usage(); + std::process::exit(0); + } + (k, _) => return Err(format!("unknown flag: {k}")), + } + } + Ok(cli) + } +} + +fn print_usage() { + eprintln!( + "urus-server [--addr=HOST:PORT] [--store=memory|sqlite] \ + [--token=TOKEN] [--db-path=PATH] [--no-auth]" + ); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +fn main() -> ExitCode { + let cli = match Cli::parse() { + Ok(c) => c, + Err(e) => { eprintln!("urus-server: {e}"); print_usage(); return ExitCode::from(2); } + }; + + let addr = match cli.addr.parse() { + Ok(a) => a, + Err(e) => { eprintln!("urus-server: bad --addr: {e}"); return ExitCode::from(2); } + }; + + // The log ring is a bounded MPSC-like buffer with a fixed capacity. It + // is shared by every connection actor (via Arc) and drained on Ctrl-C + // — but we don't wire shutdown in v1, so it's just an in-memory sink. + // Bounded means the bench can run for an hour without RSS growth. + let log_ring = LogRing::with_capacity(64 * 1024); + + // Token is wrapped in Arc<[u8]> so each plug clone is a refcount bump. + // The constant-time compare reads it as bytes. + let token: Arc<[u8]> = cli.token.as_bytes().to_vec().into(); + + // The pipeline is shape: + // logger -> request_id -> [auth ->] router + // + // /ping is mounted as a literal at the top of the router so it shares + // the middleware chain with everything else; the bench spec says S1 is + // a "naked" hello world, but in this implementation /ping still goes + // through the logger and request_id plugs. That keeps the impl simple + // and lets us measure the middleware cost as the *S2 minus S1* delta, + // which is what the spec actually asks for (§2). + // + // --no-auth turns off auth for cases where the load generator can't + // easily set headers (it shouldn't be needed for wrk2 though). + let auth_token = token.clone(); + let auth = move |conn: Conn, next: Next| auth_plug(conn, next, &auth_token); + + // The store handle is what handlers use to talk to the store actor. + // It's a wrapper around a smarm Sender; cloning is cheap (refcount). + // + // We can't actually *spawn* the store actor here (smarm requires that + // spawn be called from inside an actor, not from the bootstrap + // thread). The OnceLock in handlers::AppState defers that until the + // first request comes in. We just pass the construction parameters + // through here. + // + // For SQLite we use the configured DB path; for memory we don't + // need anything. + let store_init: Arc StoreHandle + Send + Sync + 'static> = + match cli.store { + StoreKind::Memory => Arc::new(|| spawn_memory_store()), + StoreKind::Sqlite => { + let path = cli.db_path.clone(); + Arc::new(move || spawn_sqlite_store(&path)) + } + }; + + let state = AppState::new(store_init, log_ring.clone()); + + // Build the router. Handlers close over `state` (cheap Arc clone). + // Note that handlers themselves use the AppState to resolve the + // store on first call. + let router = handlers::build_router(state.clone()); + + // Assemble the pipeline. + // + // The closures below take `Conn, Next` with explicit type annotations. + // This is necessary because `Next<'a>` is generic over a lifetime; + // without the annotation rustc binds the closure to *one specific* + // lifetime and then it doesn't satisfy `Plug`'s `Fn(Conn, Next) -> Conn` + // higher-ranked bound. With `Next` written out, the closure parses as + // `for<'a> Fn(Conn, Next<'a>) -> Conn`, which is what Plug wants. + let log_plug = { + let ring = log_ring.clone(); + move |conn: Conn, next: Next| logger_plug(conn, next, &ring) + }; + + let mut pipeline = Pipeline::new() + .plug(log_plug) + .plug(request_id_plug); + + if !cli.no_auth { + pipeline = pipeline.plug(auth); + } + + let pipeline = pipeline.plug(router); + + // Serve. We use the default Config and let urus's heuristic pick + // listener pool size = num CPUs. + let cfg = Config::new(addr); + eprintln!( + "urus-server: store={:?} addr={} auth={}", + cli.store, cli.addr, !cli.no_auth + ); + if let Err(e) = serve_with(cfg, pipeline) { + eprintln!("urus-server: serve failed: {e}"); + return ExitCode::from(1); + } + + ExitCode::SUCCESS +} diff --git a/urus-server/src/middleware.rs b/urus-server/src/middleware.rs new file mode 100644 index 0000000..da4cd46 --- /dev/null +++ b/urus-server/src/middleware.rs @@ -0,0 +1,234 @@ +//! Middleware plugs for the bench. +//! +//! Per the spec §3: +//! - Logger writes to a bounded ring (no stdout — that would dominate). +//! - Request ID is generated from a thread-local fast RNG (SplitMix64). +//! - Auth is constant-time bearer-token compare. +//! +//! Each plug is a `Fn(Conn, Next) -> Conn` closure; urus's blanket impl +//! turns them into Plug values when handed to Pipeline::plug. + +use std::cell::Cell; +use std::sync::Arc; +use std::time::Instant; + +use crossbeam_queue::ArrayQueue; +use subtle::ConstantTimeEq; +use urus::{Conn, Next}; + +// --------------------------------------------------------------------------- +// Log ring +// --------------------------------------------------------------------------- +// +// Bounded MPSC over a fixed-capacity array. We push from many connection +// actors and never read during the bench (drain on shutdown only). If the +// ring fills, new entries are dropped — the bench is the priority, not +// the log. This matches what we said in spec §1.5: warm-up + 60s measured +// at high RPS would overflow any unbounded buffer. + +#[derive(Debug, Clone)] +pub struct LogEntry { + pub method: String, + pub path: String, + pub status: u16, + pub elapsed_us: u64, +} + +#[derive(Clone)] +pub struct LogRing { + inner: Arc>, +} + +impl LogRing { + pub fn with_capacity(n: usize) -> Self { + Self { inner: Arc::new(ArrayQueue::new(n)) } + } + + /// Push an entry. Drops the entry on overflow (returns Err in + /// crossbeam's API — we discard). + pub fn push(&self, e: LogEntry) { + let _ = self.inner.push(e); + } + + /// For inspection after a run. + #[allow(dead_code)] + pub fn drain(&self) -> Vec { + let mut out = Vec::with_capacity(self.inner.len()); + while let Some(e) = self.inner.pop() { out.push(e); } + out + } +} + +// --------------------------------------------------------------------------- +// Logger plug +// --------------------------------------------------------------------------- +// +// Wraps `next`. Captures method/path *before* the inner plugs run (the +// router may move them), then captures status + elapsed after. + +pub fn logger_plug(conn: Conn, next: Next, ring: &LogRing) -> Conn { + let start = Instant::now(); + let method = conn.method.as_str().to_string(); + let path = conn.path.clone(); + let conn = next.run(conn); + let elapsed_us = start.elapsed().as_micros() as u64; + ring.push(LogEntry { + method, + path, + status: conn.status.unwrap_or(0), + elapsed_us, + }); + conn +} + +// --------------------------------------------------------------------------- +// Request ID — SplitMix64 in a thread-local, base32-encoded. +// --------------------------------------------------------------------------- +// +// We avoid global RNG / OS getrandom on the hot path. Each scheduler OS +// thread gets its own seed (Cell + thread_local) and advances it with the +// SplitMix64 step. 12 bytes of output is encoded with the RFC 4648 base32 +// alphabet (no padding, no lowercase — the header is opaque to the load +// generator). +// +// Crockford-style alphabet would be friendlier to humans but base32-hex +// is fine here. + +thread_local! { + static RNG_STATE: Cell = const { Cell::new(0) }; +} + +fn splitmix64(s: u64) -> (u64, u64) { + // Standard SplitMix64. One 64-bit step gives a uniformly distributed + // output and the next state. + let new_state = s.wrapping_add(0x9E3779B97F4A7C15); + let mut z = new_state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + (new_state, z ^ (z >> 31)) +} + +fn next_random_bytes(out: &mut [u8; 12]) { + RNG_STATE.with(|cell| { + let mut s = cell.get(); + if s == 0 { + // Lazy first-time seed. Mixing pointer + thread id + clock + // is sufficient for a bench-local RNG; this isn't crypto. + let p = cell as *const _ as usize as u64; + let t = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + s = p ^ t ^ 0xA5A5_A5A5_A5A5_A5A5; + if s == 0 { s = 1; } + } + let (s1, r1) = splitmix64(s); + let (s2, r2) = splitmix64(s1); + cell.set(s2); + out[0..8].copy_from_slice(&r1.to_le_bytes()); + out[8..12].copy_from_slice(&r2.to_le_bytes()[..4]); + }); +} + +// Base32 (RFC 4648) without padding. 12 bytes = 96 bits -> ceil(96/5) = 20 +// output chars exactly. No padding needed. +fn b32_encode(input: &[u8; 12]) -> String { + static ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut out = String::with_capacity(20); + let mut buf: u64 = 0; + let mut bits: u32 = 0; + for &b in input { + buf = (buf << 8) | (b as u64); + bits += 8; + while bits >= 5 { + bits -= 5; + let idx = ((buf >> bits) & 0x1F) as usize; + out.push(ALPHABET[idx] as char); + } + } + if bits > 0 { + let idx = ((buf << (5 - bits)) & 0x1F) as usize; + out.push(ALPHABET[idx] as char); + } + out +} + +pub fn request_id_plug(conn: Conn, next: Next) -> Conn { + let mut raw = [0u8; 12]; + next_random_bytes(&mut raw); + let id = b32_encode(&raw); + // Set on the response. We could also stash it in `conn.assigns` for + // handlers to read, but the bench doesn't need that. + let conn = next.run(conn); + conn.put_header("x-request-id", id) +} + +// --------------------------------------------------------------------------- +// Auth plug +// --------------------------------------------------------------------------- +// +// Read `Authorization: Bearer `; constant-time compare against the +// configured token. Mismatch -> 401 + halt. +// +// The token is borrowed (&[u8]) so the closure that wraps this can hold +// it via Arc<[u8]> and pass a slice in. No alloc per request. + +pub fn auth_plug(conn: Conn, next: Next, expected: &[u8]) -> Conn { + // /ping is the S1 "naked" endpoint per the bench spec — it must not + // pay the auth cost. We exempt it by path. This is a deliberate + // exception, not a generic feature; the rest of the pipeline (logger, + // request_id) still applies to /ping, which is what the spec asks for + // (S1 measures the framework floor, not "no middleware whatsoever"). + if conn.path == "/ping" { + return next.run(conn); + } + + let header = conn.headers.get("authorization"); + let presented: Option<&[u8]> = header.and_then(|h| { + // strip the "Bearer " prefix (case-insensitive per RFC 6750, but + // wrk2 sends the canonical form so an exact prefix check is fine) + h.strip_prefix("Bearer ").map(|s| s.as_bytes()) + }); + + let ok = match presented { + Some(p) if p.len() == expected.len() => + bool::from(p.ct_eq(expected)), + _ => false, + }; + + if ok { + next.run(conn) + } else { + conn.put_status(401) + .put_header("content-type", "application/json") + .put_body(&b"{\"error\":\"unauthorized\"}"[..]) + .halt() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn b32_round_trip_lengths() { + let mut raw = [0u8; 12]; + for i in 0..12u8 { raw[i as usize] = i; } + let s = b32_encode(&raw); + assert_eq!(s.len(), 20); + assert!(s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); + } + + #[test] + fn rng_advances() { + let mut a = [0u8; 12]; + let mut b = [0u8; 12]; + next_random_bytes(&mut a); + next_random_bytes(&mut b); + assert_ne!(a, b, "consecutive calls must produce different bytes"); + } +} diff --git a/urus-server/src/store.rs b/urus-server/src/store.rs new file mode 100644 index 0000000..7c18d51 --- /dev/null +++ b/urus-server/src/store.rs @@ -0,0 +1,444 @@ +//! Store actors for the urus-server bench. +//! +//! Two implementations, same protocol: +//! - Memory store: one actor owns a Vec. +//! - SQLite store: one writer actor + a pool of reader actors. Reads +//! are dispatched through a single MPSC `Receiver` shared by all +//! readers — smarm's MPSC gives us "first available reader wins" for +//! free. +//! +//! `StoreHandle` is what handlers hold. It's cheap-cloneable (it wraps a +//! pair of smarm Senders) and carries the routing logic for splitting +//! requests across the writer and the reader pool. + +use smarm::{channel, Receiver, Sender}; +use std::sync::Arc; +use std::path::Path; + +use rusqlite::{params, Connection, OpenFlags}; + +// --------------------------------------------------------------------------- +// Wire types — what the store returns over the reply channel. +// --------------------------------------------------------------------------- +// +// We send (status, body_bytes) so handlers can hand them straight to +// `Conn::put_body` without re-serialising. The store does the JSON work. + +pub type Reply = (u16, Vec); + +// Debug is intentionally not derived: `smarm::Sender` doesn't implement +// Debug, and these enums carry one. We never print them. + +pub enum ReadReq { + List { reply: Sender }, + Get { id: u64, reply: Sender }, +} + +pub enum WriteReq { + Create { body: Vec, reply: Sender }, + Update { id: u64, body: Vec, reply: Sender }, + Delete { id: u64, reply: Sender }, +} + +// --------------------------------------------------------------------------- +// User model +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct User { + id: u64, + name: String, + email: String, +} + +#[derive(serde::Deserialize)] +struct NewUser { + name: String, + email: String, +} + +// --------------------------------------------------------------------------- +// Public handle +// --------------------------------------------------------------------------- +// +// Both implementations of the store give back a StoreHandle. Internally +// we always have a "read side" and a "write side". For the memory store +// they're the same actor (so the writer Sender is just a clone of the +// reader Sender wrapped in a small adapter); we keep the split anyway for +// API uniformity. + +#[derive(Clone)] +pub struct StoreHandle { + reads: Sender, + writes: Sender, +} + +impl StoreHandle { + pub fn reads(&self) -> &Sender { &self.reads } + pub fn writes(&self) -> &Sender { &self.writes } +} + +// --------------------------------------------------------------------------- +// Memory store — one actor, one Vec. +// --------------------------------------------------------------------------- +// +// The memory store collapses reads and writes into one mailbox. We bridge +// the two-channel API by spawning a tiny *fan-in* actor whose job is to +// forward ReadReq | WriteReq into the single backing actor's mailbox. +// +// Actually, simpler: we make the backing actor accept an `enum AnyReq` +// and spawn two forwarder loops. That avoids changing the public API or +// inventing a `try_recv_either`. The cost is two extra hops per request, +// which is fine for the memory store — it's the *baseline*, not the +// target of the bench. +// +// Wait — that's two extra context switches *per request*, and the +// memory tier exists specifically to expose actor-message-pass costs. +// Tainting it with extra hops would make the bench lie. Let's do it +// properly: one actor, a `select`-shaped recv. smarm doesn't have a +// multi-channel select primitive (per the README it's a single-mailbox +// model), but we *can* have one actor own one channel that carries +// `enum Req { Read(...), Write(...) }` and expose the two Senders by +// mapping at the boundary. The mapping happens in a couple of cheap +// forwarder actors. For a *pure* in-memory benchmark we still pay one +// extra hop per request — fair across all paths. +// +// Decision: take the one extra hop for clean code. The actor-message +// cost we want to measure is "handler -> store -> handler", and the +// forwarder just makes the bookkeeping uniform. We document it. + +enum AnyReq { + Read(ReadReq), + Write(WriteReq), +} + +pub fn spawn_memory_store() -> StoreHandle { + let (any_tx, any_rx) = channel::(); + + // The backing actor. + let core_tx = any_tx.clone(); + let _ = core_tx; // keep alive via forwarders below + smarm::spawn(move || memory_store_loop(any_rx)); + + // Forwarder: ReadReq -> AnyReq::Read. + let (reads_tx, reads_rx) = channel::(); + { + let any_tx = any_tx.clone(); + smarm::spawn(move || forward_reads(reads_rx, any_tx)); + } + + // Forwarder: WriteReq -> AnyReq::Write. + let (writes_tx, writes_rx) = channel::(); + { + let any_tx = any_tx.clone(); + smarm::spawn(move || forward_writes(writes_rx, any_tx)); + } + + StoreHandle { reads: reads_tx, writes: writes_tx } +} + +fn forward_reads(rx: Receiver, tx: Sender) { + while let Ok(r) = rx.recv() { + if tx.send(AnyReq::Read(r)).is_err() { return; } + } +} + +fn forward_writes(rx: Receiver, tx: Sender) { + while let Ok(w) = rx.recv() { + if tx.send(AnyReq::Write(w)).is_err() { return; } + } +} + +fn memory_store_loop(rx: Receiver) { + let mut users: Vec = Vec::with_capacity(1024); + let mut next_id: u64 = 1; + + loop { + let req = match rx.recv() { + Ok(r) => r, + Err(_) => return, // all forwarders dropped + }; + match req { + AnyReq::Read(ReadReq::List { reply }) => { + // Cap at 100 per the spec; reuse a Vec via collect. + let snapshot: Vec<&User> = users.iter().take(100).collect(); + let body = serde_json::to_vec(&snapshot).unwrap_or_else(|_| b"[]".to_vec()); + let _ = reply.send((200, body)); + } + AnyReq::Read(ReadReq::Get { id, reply }) => { + match users.iter().find(|u| u.id == id) { + Some(u) => { + let body = serde_json::to_vec(u).unwrap(); + let _ = reply.send((200, body)); + } + None => { + let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); + } + } + } + AnyReq::Write(WriteReq::Create { body, reply }) => { + match serde_json::from_slice::(&body) { + Ok(nu) => { + let u = User { id: next_id, name: nu.name, email: nu.email }; + next_id += 1; + users.push(u.clone()); + // The bench cares about *steady-state* RPS; let + // the in-memory store cap itself so we don't + // grow to Vec. + if users.len() > 100_000 { + users.drain(..50_000); + } + let _ = reply.send((201, serde_json::to_vec(&u).unwrap())); + } + Err(_) => { + let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec())); + } + } + } + AnyReq::Write(WriteReq::Update { id, body, reply }) => { + match serde_json::from_slice::(&body) { + Ok(nu) => match users.iter_mut().find(|u| u.id == id) { + Some(u) => { + u.name = nu.name; + u.email = nu.email; + let snap = u.clone(); + let _ = reply.send((200, serde_json::to_vec(&snap).unwrap())); + } + None => { + let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); + } + }, + Err(_) => { + let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec())); + } + } + } + AnyReq::Write(WriteReq::Delete { id, reply }) => { + let before = users.len(); + users.retain(|u| u.id != id); + if users.len() < before { + let _ = reply.send((204, Vec::new())); + } else { + let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// SQLite store — one writer + N readers. +// --------------------------------------------------------------------------- +// +// Per the spec §5.1: +// - One writer actor owns a single rusqlite::Connection (read-write). +// - N=4 reader actors share a single `Receiver`. The MPSC +// semantics guarantee each request goes to exactly one reader; the +// scheduler picks whichever reader is parked. +// +// All connections open the same database file. WAL is set on the writer's +// connection (the journal mode is a database-wide setting, so any +// connection setting it sticks). busy_timeout is set on every conn so a +// momentary writer hold doesn't EBUSY the readers. +// +// Schema is created idempotently by the writer on first run. + +const SQLITE_READER_COUNT: usize = 4; + +pub fn spawn_sqlite_store(db_path: &str) -> StoreHandle { + let path: Arc = Arc::from(db_path); + + // The writer actor. + let (writes_tx, writes_rx) = channel::(); + { + let path = path.clone(); + smarm::spawn(move || sqlite_writer_loop(&path, writes_rx)); + } + + // The readers. smarm's channel is MPSC, so we can't share a single + // Receiver across N reader actors. Instead: one dispatcher owns the + // public `reads_rx`, and round-robins each request into one of N + // per-reader private channels. Cost: one extra context switch on + // every read. That's the price of fan-out on an MPSC runtime. + // + // Alternative considered: have handlers know about N senders and + // pick one themselves. Rejected: it bakes the pool size into the + // handler protocol and gives noisy load balancing under bursty + // traffic (any per-handler hashing is worse than a single dispatcher + // with strict round-robin). + let (reads_tx, reads_rx) = channel::(); + let mut per_reader_txs: Vec> = Vec::with_capacity(SQLITE_READER_COUNT); + for _ in 0..SQLITE_READER_COUNT { + let (tx, rx) = channel::(); + per_reader_txs.push(tx); + let path = path.clone(); + smarm::spawn(move || sqlite_reader_loop(&path, rx)); + } + smarm::spawn(move || dispatch_reads(reads_rx, per_reader_txs)); + + StoreHandle { reads: reads_tx, writes: writes_tx } +} + +fn open_writer(path: &str) -> Connection { + let conn = Connection::open(path).expect("sqlite: open writer"); + // WAL is the whole reason this design works: writers don't block + // readers, readers don't block writers, only writers block other + // writers (and there's only one). + conn.pragma_update(None, "journal_mode", "WAL").expect("sqlite: WAL"); + conn.pragma_update(None, "synchronous", "NORMAL").expect("sqlite: synchronous"); + conn.pragma_update(None, "busy_timeout", 5000_i64).expect("sqlite: busy_timeout"); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL + );" + ).expect("sqlite: schema"); + conn +} + +fn open_reader(path: &str) -> Connection { + let conn = Connection::open_with_flags( + Path::new(path), + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ).expect("sqlite: open reader"); + conn.pragma_update(None, "busy_timeout", 5000_i64).expect("sqlite: busy_timeout"); + // We deliberately don't set journal_mode here — readers inherit the + // DB-wide WAL setting from the writer. Setting it on a read-only + // connection would just be a no-op or an error. + conn +} + +fn sqlite_writer_loop(path: &str, rx: Receiver) { + let conn = open_writer(path); + while let Ok(req) = rx.recv() { + match req { + WriteReq::Create { body, reply } => { + match serde_json::from_slice::(&body) { + Ok(nu) => { + // BEGIN IMMEDIATE so the write lock is taken at + // statement start, not lazily. + let res = conn.execute( + "INSERT INTO users (name, email) VALUES (?1, ?2)", + params![nu.name, nu.email], + ); + match res { + Ok(_) => { + let id = conn.last_insert_rowid() as u64; + let u = User { id, name: nu.name, email: nu.email }; + let _ = reply.send((201, serde_json::to_vec(&u).unwrap())); + } + Err(e) => { + eprintln!("sqlite: insert failed: {e}"); + let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); + } + } + } + Err(_) => { + let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec())); + } + } + } + WriteReq::Update { id, body, reply } => { + match serde_json::from_slice::(&body) { + Ok(nu) => { + let res = conn.execute( + "UPDATE users SET name=?1, email=?2 WHERE id=?3", + params![nu.name, nu.email, id as i64], + ); + match res { + Ok(0) => { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); } + Ok(_) => { + let u = User { id, name: nu.name, email: nu.email }; + let _ = reply.send((200, serde_json::to_vec(&u).unwrap())); + } + Err(_) => { let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); } + } + } + Err(_) => { + let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec())); + } + } + } + WriteReq::Delete { id, reply } => { + let res = conn.execute( + "DELETE FROM users WHERE id=?1", + params![id as i64], + ); + match res { + Ok(0) => { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); } + Ok(_) => { let _ = reply.send((204, Vec::new())); } + Err(_) => { let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); } + } + } + } + } +} + +fn dispatch_reads(rx: Receiver, workers: Vec>) { + // Strict round-robin. A free-list / readiness queue would distribute + // better under skew, but it requires bidirectional signalling we + // don't have a primitive for. Round-robin is fine here: SQLite reads + // are uniform in cost (single-row PK lookup or LIMIT 100), and any + // brief reader stall just shifts a request to the next reader on + // the next pass. + let n = workers.len(); + let mut idx = 0usize; + while let Ok(req) = rx.recv() { + let target = &workers[idx % n]; + idx = idx.wrapping_add(1); + if target.send(req).is_err() { + // Reader died; skip it. (smarm restarts panicking actors + // under a supervisor, but in this binary the readers are + // unsupervised — a panic on one of them would mean a dropped + // request now and that worker is permanently silent. Fine + // for the bench; for production it'd want a supervisor.) + } + } +} + +fn sqlite_reader_loop(path: &str, rx: Receiver) { + let conn = open_reader(path); + let mut stmt_get = conn.prepare("SELECT id, name, email FROM users WHERE id=?1") + .expect("sqlite: prepare get"); + let mut stmt_list = conn.prepare("SELECT id, name, email FROM users LIMIT 100") + .expect("sqlite: prepare list"); + + while let Ok(req) = rx.recv() { + match req { + ReadReq::Get { id, reply } => { + let row = stmt_get.query_row(params![id as i64], |r| { + Ok(User { + id: r.get::<_, i64>(0)? as u64, + name: r.get(1)?, + email: r.get(2)?, + }) + }); + match row { + Ok(u) => { let _ = reply.send((200, serde_json::to_vec(&u).unwrap())); } + Err(rusqlite::Error::QueryReturnedNoRows) => + { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); } + Err(_) => + { let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); } + } + } + ReadReq::List { reply } => { + let rows = stmt_list.query_map([], |r| { + Ok(User { + id: r.get::<_, i64>(0)? as u64, + name: r.get(1)?, + email: r.get(2)?, + }) + }); + match rows { + Ok(iter) => { + let users: Vec = iter.filter_map(|r| r.ok()).collect(); + let body = serde_json::to_vec(&users).unwrap_or_else(|_| b"[]".to_vec()); + let _ = reply.send((200, body)); + } + Err(_) => { let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); } + } + } + } + } +}