diff --git a/CLAUDE.md b/CLAUDE.md index d25bf3b..ac78fec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ cargo run --bin fastedge-run -- http --port 8080 --wasm ./my_app.wasm ## Repository setup gotchas - **WIT is vendored via git subtree.** `crates/reactor/wit/` contains the `FastEdge-wit` sources directly, so no `git submodule update` step is required after clone or branch switches. To sync it with upstream, run `git subtree pull --prefix=crates/reactor/wit https://github.com/G-Core/FastEdge-wit.git main --squash`. -- **Custom Wasmtime fork.** All `wasmtime-*` deps point at `github.com/G-Core/wasmtime.git#release-36.0.0`. `.cargo/config.toml` contains a commented-out `[patch]` block that redirects those to a local sibling `../wasmtime` checkout — uncomment when developing against a local Wasmtime tree. +- **Custom Wasmtime fork.** All `wasmtime-*` deps point at `github.com/G-Core/wasmtime.git#release-48.0.0`. The repo-root `.cargo/config.toml` carries a `[patch]` block redirecting `wasmtime`, `wasmtime-wasi`, `wasmtime-wasi-nn` and `wasmtime-wasi-http` to a local sibling `../wasmtime` checkout; `wasmtime-wasi-io` and `wasmtime-environ` are not patched, so a local tree must stay in sync with the pushed branch. - **Wasm components vs core modules.** `componentize_if_necessary` (in `crates/runtime/src/lib.rs`) auto-wraps core modules using the bundled Preview1 adapter (`crates/runtime/src/adapters/wasi_snapshot_preview1.reactor.wasm`). Don't replace that adapter casually — it's pinned to the Wasmtime fork. ## Architecture @@ -71,7 +71,7 @@ Every Wasmtime `Store` holds a `Data` (in `crates/runtime/src/lib.rs`). It bu ## Crate features worth knowing -- `runtime`: `metrics` opts into Prometheus + lazy_static and propagates through `http-service/metrics`. +- `runtime`: no metrics backend is compiled in. App-call outcomes are reported through the `runtime::util::metrics` sink (`set_sink`), and live-instance counts through the plain atomics in `runtime::instances` (`live` / `flush_peak`); embedders (e.g. the FastEdge server) export them however they like. With no sink installed, reporting is a no-op. - `key-value-store`: `redis` enables the Redis-backed implementation. `runtime` already turns this on; if you depend on `key-value-store` from elsewhere, opt in explicitly. - `cache`: no features yet — backend choice is at runtime via the `Arc` injected into `Data`. diff --git a/Cargo.lock b/Cargo.lock index a13573d..fc4e979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.25.1" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ "gimli", ] @@ -332,81 +332,21 @@ dependencies = [ "tracing", ] -[[package]] -name = "cap-fs-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" -dependencies = [ - "cap-primitives", - "cap-std", - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "cap-net-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" -dependencies = [ - "cap-primitives", - "cap-std", - "rustix 1.1.4", - "smallvec", -] - [[package]] name = "cap-primitives" -version = "3.4.5" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" dependencies = [ "ambient-authority", "fs-set-times", "io-extras", - "io-lifetimes", + "io-lifetimes 3.0.1", "ipnet", "maybe-owned", - "rustix 1.1.4", + "rustix", "rustix-linux-procfs", - "windows-sys 0.59.0", - "winx", -] - -[[package]] -name = "cap-rand" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" -dependencies = [ - "ambient-authority", - "rand 0.8.7", -] - -[[package]] -name = "cap-std" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" -dependencies = [ - "cap-primitives", - "io-extras", - "io-lifetimes", - "rustix 1.1.4", -] - -[[package]] -name = "cap-time-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" -dependencies = [ - "ambient-authority", - "cap-primitives", - "iana-time-zone", - "once_cell", - "rustix 1.1.4", + "windows-sys 0.61.2", "winx", ] @@ -434,6 +374,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -551,9 +502,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpp_demangle" -version = "0.4.5" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" dependencies = [ "cfg-if", ] @@ -567,72 +518,97 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-assembler-x64" -version = "0.123.12" +version = "0.135.2" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.123.12" +version = "0.135.2" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.123.12" +version = "0.135.2" dependencies = [ - "cranelift-entity 0.123.12", + "cranelift-entity 0.135.2", + "wasmtime-internal-core 48.0.2", +] + +[[package]] +name = "cranelift-bforest" +version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" +dependencies = [ + "cranelift-entity 0.135.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "wasmtime-internal-core 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", ] [[package]] name = "cranelift-bitset" -version = "0.123.12" +version = "0.135.2" dependencies = [ "serde", "serde_derive", + "wasmtime-internal-core 48.0.2", ] [[package]] name = "cranelift-bitset" -version = "0.123.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" +version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "serde", "serde_derive", + "wasmtime-internal-core 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", ] [[package]] name = "cranelift-codegen" -version = "0.123.12" +version = "0.135.2" dependencies = [ "bumpalo", "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset 0.123.12", + "cranelift-bforest 0.135.2", + "cranelift-bitset 0.135.2", "cranelift-codegen-meta", "cranelift-codegen-shared", "cranelift-control", - "cranelift-entity 0.123.12", + "cranelift-entity 0.135.2", "cranelift-isle", "gimli", - "hashbrown 0.15.5", + "hashbrown 0.17.1", + "libm", "log", + "postcard", "pulley-interpreter", "regalloc2", "rustc-hash", "serde", + "serde_derive", + "sha2", "smallvec", "target-lexicon", - "wasmtime-internal-math", + "wasmtime-internal-core 48.0.2", ] [[package]] name = "cranelift-codegen-meta" -version = "0.123.12" +version = "0.135.2" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -643,39 +619,42 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.123.12" +version = "0.135.2" [[package]] name = "cranelift-control" -version = "0.123.12" +version = "0.135.2" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.123.12" +version = "0.135.2" dependencies = [ - "cranelift-bitset 0.123.12", + "cranelift-bitset 0.135.2", "serde", "serde_derive", + "wasmtime-internal-core 48.0.2", ] [[package]] name = "cranelift-entity" -version = "0.123.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" +version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ - "cranelift-bitset 0.123.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", + "cranelift-bitset 0.135.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", "serde", "serde_derive", + "wasmtime-internal-core 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", ] [[package]] name = "cranelift-frontend" -version = "0.123.12" +version = "0.135.2" dependencies = [ "cranelift-codegen", + "hashbrown 0.17.1", "log", "smallvec", "target-lexicon", @@ -683,11 +662,11 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.123.12" +version = "0.135.2" [[package]] name = "cranelift-native" -version = "0.123.12" +version = "0.135.2" dependencies = [ "cranelift-codegen", "libc", @@ -696,7 +675,7 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.123.12" +version = "0.135.2" [[package]] name = "crc32fast" @@ -933,12 +912,6 @@ dependencies = [ "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 = "fancy-regex" version = "0.13.0" @@ -984,17 +957,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix 1.1.4", - "windows-sys 0.59.0", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1002,16 +964,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "fnv" -version = "1.0.7" +name = "fixedbitset" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] -name = "foldhash" -version = "0.1.5" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" @@ -1049,8 +1011,8 @@ version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ - "io-lifetimes", - "rustix 1.1.4", + "io-lifetimes 2.0.4", + "rustix", "windows-sys 0.59.0", ] @@ -1142,25 +1104,17 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "fxprof-processed-profile" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ "bitflags 2.13.1", "debugid", - "fxhash", + "rustc-hash", "serde", + "serde_derive", "serde_json", ] @@ -1443,15 +1397,17 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] name = "gimli" -version = "0.32.3" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ - "fallible-iterator", + "fnv", + "hashbrown 0.16.1", "indexmap", "stable_deref_trait", ] @@ -1490,23 +1446,13 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", - "serde", -] - [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1514,6 +1460,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] [[package]] name = "heck" @@ -1527,12 +1478,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "http" version = "1.4.2" @@ -1601,7 +1546,6 @@ dependencies = [ "hyper", "hyper-util", "key-value-store", - "nanoid", "reactor", "runtime", "secret", @@ -1612,7 +1556,6 @@ dependencies = [ "tracing", "tracing-test", "utils", - "wasi-common", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", @@ -1842,12 +1785,12 @@ dependencies = [ [[package]] name = "io-extras" -version = "0.18.4" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ - "io-lifetimes", - "windows-sys 0.59.0", + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", ] [[package]] @@ -1856,6 +1799,12 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.0" @@ -2007,12 +1956,6 @@ dependencies = [ "libc", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2042,12 +1985,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "mach2" -version = "0.4.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "matchers" @@ -2076,7 +2016,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix 1.1.4", + "rustix", ] [[package]] @@ -2143,15 +2083,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "nanoid" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873" -dependencies = [ - "rand 0.9.5", -] - [[package]] name = "native-tls" version = "0.2.18" @@ -2300,12 +2231,12 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "crc32fast", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "indexmap", "memchr", ] @@ -2439,6 +2370,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2529,7 +2470,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit", ] [[package]] @@ -2541,78 +2482,19 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "procfs" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" -dependencies = [ - "bitflags 2.13.1", - "hex", - "procfs-core", - "rustix 0.38.44", -] - -[[package]] -name = "procfs-core" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" -dependencies = [ - "bitflags 2.13.1", - "hex", -] - -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "libc", - "memchr", - "parking_lot", - "procfs", - "protobuf", - "thiserror 2.0.19", -] - -[[package]] -name = "protobuf" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" -dependencies = [ - "once_cell", - "protobuf-support", - "thiserror 1.0.69", -] - -[[package]] -name = "protobuf-support" -version = "3.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" -dependencies = [ - "thiserror 1.0.69", -] - [[package]] name = "pulley-interpreter" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "cranelift-bitset 0.123.12", + "cranelift-bitset 0.135.2", "log", "pulley-macros", - "wasmtime-internal-math", + "wasmtime-internal-core 48.0.2", ] [[package]] name = "pulley-macros" -version = "36.0.12" +version = "48.0.2" dependencies = [ "proc-macro2", "quote", @@ -2668,9 +2550,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2687,6 +2569,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2725,6 +2618,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -2838,15 +2737,16 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.12.2" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "log", "rustc-hash", + "serde", "smallvec", ] @@ -2906,27 +2806,28 @@ dependencies = [ "claims", "http", "http-backend", + "http-body", + "http-body-util", "key-value-store", - "lazy_static", "moka", - "prometheus", + "rand 0.8.8", "secret", "serde", "serde_json", "smol_str", + "tempfile", "tokio", "tokio-util", "tracing", "utils", - "wasi-common", "wasmtime", - "wasmtime-environ 36.0.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", + "wasmtime-environ 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", "wasmtime-wasi", "wasmtime-wasi-http", - "wasmtime-wasi-io 36.0.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", + "wasmtime-wasi-io 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", "wasmtime-wasi-nn", "wat", - "wit-component", + "wit-component 0.246.2", ] [[package]] @@ -2941,19 +2842,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -2963,7 +2851,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -2974,16 +2862,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.1.4", + "rustix", ] [[package]] name = "rustls" -version = "0.22.4" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", + "once_cell", "ring", "rustls-pki-types", "rustls-webpki", @@ -3002,9 +2891,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -3165,11 +3054,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -3185,7 +3074,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3349,22 +3238,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "system-interface" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" -dependencies = [ - "bitflags 2.13.1", - "cap-fs-ext", - "cap-std", - "fd-lock", - "io-lifetimes", - "rustix 0.38.44", - "windows-sys 0.59.0", - "winx", -] - [[package]] name = "tagptr" version = "0.2.0" @@ -3386,7 +3259,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -3531,12 +3404,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.25.0" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", - "rustls-pki-types", "tokio", ] @@ -3569,23 +3441,26 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -3597,20 +3472,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.13+spec-1.1.0" @@ -3633,10 +3494,10 @@ dependencies = [ ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -3666,7 +3527,6 @@ 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", @@ -3885,30 +3745,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi-common" -version = "36.0.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" -dependencies = [ - "anyhow", - "bitflags 2.13.1", - "cap-fs-ext", - "cap-rand", - "cap-std", - "cap-time-ext", - "fs-set-times", - "io-extras", - "io-lifetimes", - "log", - "rustix 1.1.4", - "system-interface", - "thiserror 2.0.19", - "tracing", - "wasmtime", - "wiggle 36.0.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", - "windows-sys 0.60.2", -] - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -3964,13 +3800,20 @@ dependencies = [ ] [[package]] -name = "wasm-encoder" -version = "0.236.1" +name = "wasm-compose" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" +checksum = "8fd717357bff09b5f1ea49aea8ed1cd149b0c521823a1cc70bc2ac16b90b651a" dependencies = [ - "leb128fmt", - "wasmparser 0.236.1", + "anyhow", + "heck", + "indexmap", + "log", + "petgraph", + "smallvec", + "wasm-encoder 0.254.0", + "wasmparser 0.254.0", + "wat", ] [[package]] @@ -4006,16 +3849,15 @@ dependencies = [ ] [[package]] -name = "wasmparser" -version = "0.236.1" +name = "wasm-metadata" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +checksum = "b01df5f3b4ca7881e843f3bc0fb8a3905d79c68692250dcb8e33e698705ccdb6" dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.15.5", + "anyhow", "indexmap", - "semver", - "serde", + "wasm-encoder 0.254.0", + "wasmparser 0.254.0", ] [[package]] @@ -4037,37 +3879,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" dependencies = [ "bitflags 2.13.1", + "hashbrown 0.17.1", "indexmap", "semver", + "serde", ] [[package]] name = "wasmprinter" -version = "0.236.1" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" +checksum = "64e3ba11e024f504698f87b629b2b66c22a1231758de7607754963f7d198be39" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.236.1", + "wasmparser 0.254.0", ] [[package]] name = "wasmtime" -version = "36.0.12" +version = "48.0.2" dependencies = [ "addr2line", - "anyhow", "async-trait", "bitflags 2.13.1", "bumpalo", "cc", - "cfg-if", "encoding_rs", + "futures", "fxprof-processed-profile", "gimli", - "hashbrown 0.15.5", - "indexmap", "ittapi", "libc", "log", @@ -4078,42 +3919,44 @@ dependencies = [ "postcard", "pulley-interpreter", "rayon", - "rustix 1.1.4", + "rustix", "semver", "serde", "serde_derive", "serde_json", "smallvec", "target-lexicon", - "wasm-encoder 0.236.1", - "wasmparser 0.236.1", - "wasmtime-environ 36.0.12", - "wasmtime-internal-asm-macros", + "tempfile", + "wasm-compose", + "wasm-encoder 0.254.0", + "wasmparser 0.254.0", + "wasmtime-environ 48.0.2", "wasmtime-internal-cache", "wasmtime-internal-component-macro", "wasmtime-internal-component-util", + "wasmtime-internal-core 48.0.2", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", - "wasmtime-internal-winch", "wat", - "windows-sys 0.60.2", + "windows-sys 0.61.2", + "wit-parser 0.254.0", ] [[package]] name = "wasmtime-environ" -version = "36.0.12" +version = "48.0.2" dependencies = [ "anyhow", "cpp_demangle", - "cranelift-bitset 0.123.12", - "cranelift-entity 0.123.12", + "cranelift-bforest 0.135.2", + "cranelift-bitset 0.135.2", + "cranelift-entity 0.135.2", "gimli", + "hashbrown 0.17.1", "indexmap", "log", "object", @@ -4122,62 +3965,61 @@ dependencies = [ "semver", "serde", "serde_derive", + "sha2", "smallvec", "target-lexicon", - "wasm-encoder 0.236.1", - "wasmparser 0.236.1", + "wasm-encoder 0.254.0", + "wasmparser 0.254.0", "wasmprinter", "wasmtime-internal-component-util", + "wasmtime-internal-core 48.0.2", ] [[package]] name = "wasmtime-environ" -version = "36.0.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" +version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "anyhow", - "cranelift-bitset 0.123.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", - "cranelift-entity 0.123.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", + "cranelift-bforest 0.135.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "cranelift-bitset 0.135.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "cranelift-entity 0.135.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", "gimli", + "hashbrown 0.17.1", "indexmap", "log", "object", "postcard", "serde", "serde_derive", + "sha2", "smallvec", "target-lexicon", - "wasmparser 0.236.1", -] - -[[package]] -name = "wasmtime-internal-asm-macros" -version = "36.0.12" -dependencies = [ - "cfg-if", + "wasmparser 0.254.0", + "wasmtime-internal-core 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", ] [[package]] name = "wasmtime-internal-cache" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", "base64", "directories-next", "log", "postcard", - "rustix 1.1.4", + "rustix", "serde", "serde_derive", "sha2", "toml", - "windows-sys 0.60.2", + "wasmtime-environ 48.0.2", + "windows-sys 0.61.2", "zstd", ] [[package]] name = "wasmtime-internal-component-macro" -version = "36.0.12" +version = "48.0.2" dependencies = [ "anyhow", "proc-macro2", @@ -4185,22 +4027,40 @@ dependencies = [ "syn 2.0.119", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser 0.236.1", + "wit-parser 0.254.0", ] [[package]] name = "wasmtime-internal-component-util" -version = "36.0.12" +version = "48.0.2" [[package]] -name = "wasmtime-internal-cranelift" -version = "36.0.12" +name = "wasmtime-internal-core" +version = "48.0.2" dependencies = [ "anyhow", - "cfg-if", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-core" +version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "48.0.2" +dependencies = [ "cranelift-codegen", "cranelift-control", - "cranelift-entity 0.123.12", + "cranelift-entity 0.135.2", "cranelift-frontend", "cranelift-native", "gimli", @@ -4211,137 +4071,102 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.19", - "wasmparser 0.236.1", - "wasmtime-environ 36.0.12", - "wasmtime-internal-math", + "wasmparser 0.254.0", + "wasmtime-environ 48.0.2", + "wasmtime-internal-core 48.0.2", + "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-fiber" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", "cc", - "cfg-if", "libc", - "rustix 1.1.4", - "wasmtime-internal-asm-macros", + "rustix", + "wasmtime-environ 48.0.2", "wasmtime-internal-versioned-export-macros", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "wasmtime-internal-jit-debug" -version = "36.0.12" +version = "48.0.2" dependencies = [ "cc", "object", - "rustix 1.1.4", + "rustix", "wasmtime-internal-versioned-export-macros", ] [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", - "cfg-if", "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "wasmtime-internal-math" -version = "36.0.12" -dependencies = [ - "libm", + "wasmtime-internal-core 48.0.2", + "windows-sys 0.61.2", ] -[[package]] -name = "wasmtime-internal-slab" -version = "36.0.12" - [[package]] name = "wasmtime-internal-unwinder" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", - "cfg-if", "cranelift-codegen", "log", "object", + "wasmtime-environ 48.0.2", ] [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "36.0.12" +version = "48.0.2" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "wasmtime-internal-winch" -version = "36.0.12" -dependencies = [ - "anyhow", - "cranelift-codegen", - "gimli", - "object", - "target-lexicon", - "wasmparser 0.236.1", - "wasmtime-environ 36.0.12", - "wasmtime-internal-cranelift", - "winch-codegen", -] - [[package]] name = "wasmtime-internal-wit-bindgen" -version = "36.0.12" +version = "48.0.2" dependencies = [ "anyhow", "bitflags 2.13.1", "heck", "indexmap", - "wit-parser 0.236.1", + "wit-component 0.254.0", + "wit-parser 0.254.0", ] [[package]] name = "wasmtime-wasi" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", "async-trait", "bitflags 2.13.1", "bytes", - "cap-fs-ext", - "cap-net-ext", - "cap-rand", - "cap-std", - "cap-time-ext", - "fs-set-times", + "cap-primitives", "futures", - "io-extras", - "io-lifetimes", - "rustix 1.1.4", - "system-interface", + "rand 0.10.3", + "rustix", + "rustix-linux-procfs", "thiserror 2.0.19", "tokio", "tracing", "url", "wasmtime", - "wasmtime-wasi-io 36.0.12", - "wiggle 36.0.12", - "windows-sys 0.60.2", + "wasmtime-wasi-io 48.0.2", + "wiggle", + "windows-sys 0.61.2", + "winx", ] [[package]] name = "wasmtime-wasi-http" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", "async-trait", "bytes", "futures", @@ -4349,44 +4174,44 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "pin-project-lite", "rustls", "tokio", "tokio-rustls", "tracing", "wasmtime", "wasmtime-wasi", - "wasmtime-wasi-io 36.0.12", - "webpki-roots 0.26.11", + "wasmtime-wasi-io 48.0.2", + "webpki-roots", ] [[package]] name = "wasmtime-wasi-io" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", "async-trait", "bytes", "futures", + "tracing", "wasmtime", ] [[package]] name = "wasmtime-wasi-io" -version = "36.0.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" +version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ - "anyhow", "async-trait", "bytes", "futures", + "tracing", "wasmtime", ] [[package]] name = "wasmtime-wasi-nn" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", "candle-core", "candle-nn", "candle-transformers", @@ -4395,7 +4220,7 @@ dependencies = [ "tracing", "walkdir", "wasmtime", - "wiggle 36.0.12", + "wiggle", "windows", ] @@ -4430,15 +4255,6 @@ dependencies = [ "wast 254.0.0", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - [[package]] name = "webpki-roots" version = "1.0.9" @@ -4450,75 +4266,36 @@ dependencies = [ [[package]] name = "wiggle" -version = "36.0.12" -dependencies = [ - "anyhow", - "async-trait", - "bitflags 2.13.1", - "thiserror 2.0.19", - "tracing", - "wasmtime", - "wiggle-macro 36.0.12", -] - -[[package]] -name = "wiggle" -version = "36.0.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" +version = "48.0.2" dependencies = [ - "anyhow", - "async-trait", "bitflags 2.13.1", "thiserror 2.0.19", "tracing", "wasmtime", - "wiggle-macro 36.0.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", -] - -[[package]] -name = "wiggle-generate" -version = "36.0.12" -dependencies = [ - "anyhow", - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", - "witx", + "wasmtime-environ 48.0.2", + "wiggle-macro", ] [[package]] name = "wiggle-generate" -version = "36.0.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" +version = "48.0.2" dependencies = [ - "anyhow", "heck", "proc-macro2", "quote", "syn 2.0.119", + "wasmtime-environ 48.0.2", "witx", ] [[package]] name = "wiggle-macro" -version = "36.0.12" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "wiggle-generate 36.0.12", -] - -[[package]] -name = "wiggle-macro" -version = "36.0.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" +version = "48.0.2" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "wiggle-generate 36.0.12 (git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0)", + "wiggle-generate", ] [[package]] @@ -4552,24 +4329,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "winch-codegen" -version = "36.0.12" -dependencies = [ - "anyhow", - "cranelift-assembler-x64", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon", - "thiserror 2.0.19", - "wasmparser 0.236.1", - "wasmtime-environ 36.0.12", - "wasmtime-internal-cranelift", - "wasmtime-internal-math", -] - [[package]] name = "windows" version = "0.52.0" @@ -4842,9 +4601,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -4885,27 +4641,28 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.246.2", - "wasm-metadata", + "wasm-metadata 0.246.2", "wasmparser 0.246.2", "wit-parser 0.246.2", ] [[package]] -name = "wit-parser" -version = "0.236.1" +name = "wit-component" +version = "0.254.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" +checksum = "b0e65bb94c369b3c4741ce3d1d2704b1fec93db7c540df0e521a097e7ceeb5be" dependencies = [ "anyhow", - "id-arena", + "bitflags 2.13.1", "indexmap", "log", - "semver", "serde", "serde_derive", "serde_json", - "unicode-xid", - "wasmparser 0.236.1", + "wasm-encoder 0.254.0", + "wasm-metadata 0.254.0", + "wasmparser 0.254.0", + "wit-parser 0.254.0", ] [[package]] @@ -4927,6 +4684,25 @@ dependencies = [ "wasmparser 0.246.2", ] +[[package]] +name = "wit-parser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1655131e4f7d3f0cb141f6eca71315ca40eff0f3d4de7cff0a82bacedd8c89b4" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser 0.254.0", +] + [[package]] name = "witx" version = "0.9.1" @@ -5126,3 +4902,7 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "fastedge_proxywasm" +version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index e3c6101..88d9284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,13 +15,12 @@ tracing = "0.1" hyper = { version = "1", features = ["full"] } http = "1.4" async-trait = "0.1" -wasmtime = { git = "https://github.com/G-Core/wasmtime.git", features = ["component-model"], branch = "release-36.0.0"} -wasmtime-wasi = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-36.0.0"} -wasmtime-wasi-io = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-36.0.0"} -wasi-common = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-36.0.0"} -wasmtime-wasi-nn = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-36.0.0", features = ["openvino", "candle"] } -wasmtime-wasi-http = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-36.0.0"} -wasmtime-environ = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-36.0.0"} +wasmtime = { git = "https://github.com/G-Core/wasmtime.git", features = ["component-model"], branch = "release-48.0.0" } +wasmtime-wasi = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-48.0.0" } +wasmtime-wasi-io = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-48.0.0" } +wasmtime-wasi-nn = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-48.0.0", features = ["openvino", "candle"] } +wasmtime-wasi-http = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-48.0.0" } +wasmtime-environ = { git = "https://github.com/G-Core/wasmtime.git", branch = "release-48.0.0" } bytesize = "2.3.1" clap = { version = "4", features = ["derive"] } @@ -56,7 +55,7 @@ http = { workspace = true } tokio = { workspace = true } wasmtime = { workspace = true } smol_str = { workspace = true } -async-trait = {workspace = true} +async-trait = { workspace = true } clap = { version = "4.6", features = ["derive"] } pretty_env_logger = "0.5" runtime = { path = "crates/runtime", default-features = false } @@ -69,7 +68,7 @@ cache = { path = "crates/cache" } hyper-tls = "0.6" hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1", "tokio"] } http-body-util = "0.1" -bytesize = { workspace = true} +bytesize = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] shellflip = "2.1.2" diff --git a/crates/cache/src/lib.rs b/crates/cache/src/lib.rs index b0d4180..7913482 100644 --- a/crates/cache/src/lib.rs +++ b/crates/cache/src/lib.rs @@ -49,42 +49,84 @@ impl CacheImpl { pub fn new(backend: Arc) -> Self { Self { backend } } + + /// Get the value associated with `key`. + pub async fn get(&self, key: &str) -> Result, Error> { + self.backend.get(key).await + } + + /// Set the value for `key` with an optional expiry in milliseconds. + pub async fn set(&self, key: &str, value: Payload, ttl_ms: Option) -> Result<(), Error> { + self.backend.set(key, value, ttl_ms).await + } + + /// Delete the key-value pair associated with `key`. + pub async fn delete(&self, key: &str) -> Result<(), Error> { + self.backend.delete(key).await + } + + /// Check whether `key` exists in the cache. + pub async fn exists(&self, key: &str) -> Result { + self.backend.exists(key).await + } + + /// Increment the integer value stored at `key` by `delta`. + pub async fn incr(&self, key: &str, delta: i64) -> Result { + self.backend.incr(key, delta).await + } + + /// Set or update the expiry of `key` to `ttl_ms` milliseconds from now. + pub async fn expire(&self, key: &str, ttl_ms: u64) -> Result { + self.backend.expire(key, ttl_ms).await + } + + /// Purge every cache entry owned by the calling application. + pub async fn purge(&self) -> Result { + self.backend.purge().await + } + + /// Purge every cache entry whose key begins with `prefix`. + pub async fn purge_prefix(&self, prefix: &str) -> Result { + self.backend.purge_prefix(prefix).await + } } // Empty marker trait impl cache_types::Host for CacheImpl {} // Implement the sync-style cache interface (gcore:fastedge/cache-sync) -// This is simpler and serializes calls through &mut self. +// This is simpler and serializes calls through &mut self. Every method +// delegates to the inherent `CacheImpl` method of the same name, which the +// ProxyWasm host functions also call. impl cache_sync::Host for CacheImpl { async fn get(&mut self, key: String) -> Result, Error> { - self.backend.get(&key).await + CacheImpl::get(self, &key).await } async fn set(&mut self, key: String, value: Payload, ttl_ms: Option) -> Result<(), Error> { - self.backend.set(&key, value, ttl_ms).await + CacheImpl::set(self, &key, value, ttl_ms).await } async fn delete(&mut self, key: String) -> Result<(), Error> { - self.backend.delete(&key).await + CacheImpl::delete(self, &key).await } async fn exists(&mut self, key: String) -> Result { - self.backend.exists(&key).await + CacheImpl::exists(self, &key).await } async fn incr(&mut self, key: String, delta: i64) -> Result { - self.backend.incr(&key, delta).await + CacheImpl::incr(self, &key, delta).await } async fn expire(&mut self, key: String, ttl_ms: u64) -> Result { - self.backend.expire(&key, ttl_ms).await + CacheImpl::expire(self, &key, ttl_ms).await } async fn purge(&mut self) -> Result { - self.backend.purge().await + CacheImpl::purge(self).await } async fn purge_prefix(&mut self, prefix: String) -> Result { - self.backend.purge_prefix(&prefix).await + CacheImpl::purge_prefix(self, &prefix).await } } diff --git a/crates/http-backend/src/access_log.rs b/crates/http-backend/src/access_log.rs new file mode 100644 index 0000000..cdc88eb --- /dev/null +++ b/crates/http-backend/src/access_log.rs @@ -0,0 +1,58 @@ +//! Outbound-request access-log seam. +//! +//! Distinct from [`crate::stats`] (billing/Prometheus timing): this sink emits +//! one access-log record per external HTTP request the guest makes. It is kept +//! dependency-free — primitive argument types only — so `http-backend` need not +//! depend on the runtime's access-log types. The concrete implementation (which +//! builds the runtime record and ships it) lives in the embedder. + +use smol_str::SmolStr; +use std::sync::Arc; +use std::time::Duration; + +/// Sink for one completed outbound (external) HTTP request. +/// +/// `status` is `0` when the request failed before a response was received +/// (connection error / timeout). Implementations must be non-blocking — this is +/// called on the request hot path. +pub trait ExtRequestLog: Send + Sync { + /// Record one completed outbound request, correlated to the inbound request + /// via `traceparent`. + fn log_ext_request( + &self, + traceparent: &str, + app_id: u64, + method: &str, + uri: &str, + status: u16, + elapsed: Duration, + ); +} + +/// A cloneable, connector-agnostic handle carrying everything needed to log one +/// outbound request once it completes. Used by the WASI-HTTP path, where the +/// send happens in a spawned task and the [`crate::Backend`] (generic over its +/// connector) is not available; the handle is captured before the spawn. +#[derive(Clone)] +pub struct ExtRequestLogHandle { + log: Arc, + traceparent: SmolStr, + app_id: u64, +} + +impl ExtRequestLogHandle { + pub fn new(log: Arc, traceparent: SmolStr, app_id: u64) -> Self { + Self { + log, + traceparent, + app_id, + } + } + + /// Emit one record for a completed outbound request. `status` is `0` on + /// failure (no response received). + pub fn log(&self, method: &str, uri: &str, status: u16, elapsed: Duration) { + self.log + .log_ext_request(&self.traceparent, self.app_id, method, uri, status, elapsed); + } +} diff --git a/crates/http-backend/src/lib.rs b/crates/http-backend/src/lib.rs index e558387..02aa886 100644 --- a/crates/http-backend/src/lib.rs +++ b/crates/http-backend/src/lib.rs @@ -1,5 +1,7 @@ +pub mod access_log; pub mod stats; +use crate::access_log::{ExtRequestLog, ExtRequestLogHandle}; use smol_str::{SmolStr, ToSmolStr}; use std::fmt::Debug; use std::future::Future; @@ -92,6 +94,12 @@ pub struct Backend { epoch_exclude_http_wait: bool, cdn_real_host: Option, app_id: Option, + /// Sink for the per-outbound-request access log. Baked into the backend + /// template by the embedder; `None` disables outbound-request logging. + ext_http_log: Option>, + /// W3C `traceparent` of the inbound request, set per request so each + /// outbound record can be correlated back to it. + traceparent: Option, } pub struct Builder { @@ -144,6 +152,8 @@ impl Builder { epoch_exclude_http_wait: false, cdn_real_host: None, app_id: None, + ext_http_log: None, + traceparent: None, } } } @@ -183,6 +193,48 @@ impl Backend { self.app_id } + /// Set the outbound-request access-log sink (baked into the template once). + pub fn set_ext_http_log(&mut self, log: Arc) { + self.ext_http_log = Some(log); + } + + /// Set the inbound `traceparent` used to correlate outbound records. + pub fn set_traceparent(&mut self, traceparent: SmolStr) { + self.traceparent = Some(traceparent); + } + + /// Snapshot the outbound-request log sink + correlation context into a + /// cloneable handle, for logging from a context where `self` is not + /// available (e.g. the WASI-HTTP spawned send task). `None` when no sink or + /// `traceparent` is set. + pub fn ext_request_log_handle(&self) -> Option { + match (self.ext_http_log.as_ref(), self.traceparent.as_ref()) { + (Some(log), Some(traceparent)) => Some(ExtRequestLogHandle::new( + log.clone(), + traceparent.clone(), + self.app_id.unwrap_or(0), + )), + _ => None, + } + } + + /// Emit one access-log record for a completed outbound request. No-op when + /// no sink or no `traceparent` is set. `status` is `0` on failure. + pub fn log_ext_request( + &self, + app_id: u64, + method: &str, + uri: &str, + status: u16, + elapsed: Duration, + ) { + if let (Some(log), Some(traceparent)) = + (self.ext_http_log.as_ref(), self.traceparent.as_ref()) + { + log.log_ext_request(traceparent, app_id, method, uri, status, elapsed); + } + } + /// Set external request stats pub fn set_ext_http_stats(&mut self, stats: Arc) { self.ext_http_stats.replace(stats); @@ -424,6 +476,11 @@ where self.max_sub_requests -= 1; } + // Capture the outbound method/target for the access-log record before + // `make_request` consumes the request. + let log_method = method_str(&req.method); + let log_uri = req.uri.to_smolstr(); + let request = self.make_request(req).map_err(|error| { warn!(cause=?error, "making request to backend"); HttpError::RequestError @@ -483,14 +540,43 @@ where }) } .await; + let elapsed = started.elapsed(); if self.epoch_exclude_http_wait { self.epoch_pause_ms - .fetch_add(started.elapsed().as_millis() as u64, Ordering::Relaxed); + .fetch_add(elapsed.as_millis() as u64, Ordering::Relaxed); } + + // Emit one access-log record for this outbound request. Failures before a + // response (connection error / timeout) are logged with status 0. + let status = match &result { + Ok(response) => response.status, + Err(_) => 0, + }; + self.log_ext_request( + self.app_id.unwrap_or(0), + &log_method, + &log_uri, + status, + elapsed, + ); result } } +/// Stable string label for a guest [`Method`], for the access log. +fn method_str(method: &Method) -> SmolStr { + match method { + Method::Get => "GET", + Method::Post => "POST", + Method::Put => "PUT", + Method::Delete => "DELETE", + Method::Head => "HEAD", + Method::Patch => "PATCH", + Method::Options => "OPTIONS", + } + .into() +} + // extract canonical host name fn canonical_host_name(headers: &Headers, original_uri: &Uri) -> Result { let host = headers.iter().find_map(|(k, v)| { diff --git a/crates/http-service/Cargo.toml b/crates/http-service/Cargo.toml index 8abc003..2456627 100644 --- a/crates/http-service/Cargo.toml +++ b/crates/http-service/Cargo.toml @@ -7,7 +7,6 @@ authors.workspace = true [features] default = [] -metrics = ["runtime/metrics"] [dependencies] anyhow = { workspace = true } @@ -17,7 +16,6 @@ http = { workspace = true } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } wasmtime-wasi-nn = { workspace = true } -wasi-common = { workspace = true } wasmtime-wasi-http = { workspace = true } tracing = { workspace = true } smol_str = { workspace = true } @@ -27,7 +25,6 @@ http-backend = { path = "../http-backend" } utils = { path = "../utils" } secret = { path = "../secret" } cache = { path = "../cache" } -nanoid = "0.5" async-trait = "0.1" hyper-util = { version = "0.1", features = ["server", "server-graceful"] } http-body-util = "0.1" diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index 880b043..6997297 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -13,7 +13,7 @@ use runtime::{InstancePre, store::StoreBuilder}; use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::time::Duration; -use wasmtime_wasi_http::body::HyperOutgoingBody; +use wasmtime_wasi_http::p2::body::HyperOutgoingBody; /// Execute context used by ['HttpService'] #[derive(Clone)] @@ -98,6 +98,13 @@ where let mut http_backend = self.backend; http_backend.set_app_id(self.app_id); + if let Some(traceparent) = parts + .headers + .get(executor::TRACEPARENT) + .and_then(|v| v.to_str().ok()) + { + http_backend.set_traceparent(traceparent.into()); + } http_backend .propagate_headers(parts.headers.clone()) .context("propagate headers")?; @@ -133,9 +140,9 @@ where // declared minimum memory exceeds `mem_limit`) is recorded by the // limiter; classify it as out-of-memory instead of a generic error. if store.is_oom() { - return Err(runtime::store::OutOfMemory(error).into()); + return Err(runtime::store::OutOfMemory(error.into()).into()); } - return Err(error); + return Err(error.into()); } }; let http_handler = @@ -159,7 +166,7 @@ where .write_msg(format!("Execution error: {}", error)) .await; } - return Err(error); + return Err(error.into()); } }; let status_code = StatusCode::try_from(resp.status)?; @@ -178,7 +185,7 @@ where let body = resp .body - .map(|b| Full::from(b).map_err(|never| match never {}).boxed()) + .map(|b| Full::from(b).map_err(|never| match never {}).boxed_unsync()) .unwrap_or_default(); builder.body(body).map_err(anyhow::Error::msg) } @@ -283,6 +290,8 @@ mod tests { fn count_kv_read(&self, _value: i32) {} fn count_kv_byod_read(&self, _value: i32) {} + + fn count_kv_read_cached(&self) {} } impl UserDiagStats for TestStats { @@ -342,6 +351,10 @@ mod tests { &self.engine } + fn graph_registry(&self) -> runtime::CachedGraphRegistry { + runtime::CachedGraphRegistry::new() + } + fn make_secret_store(&self, _secrets: &Vec) -> anyhow::Result { todo!() } @@ -354,6 +367,7 @@ mod tests { &self, _request_id: &SmolStr, _app: &SmolStr, + _caller_ip: std::net::Ipv4Addr, _cfg: &App, ) -> Arc { Arc::new(TestStats) @@ -371,11 +385,11 @@ mod tests { DUMMY_SAMPLE }; let wasm_sample = componentize_if_necessary(bytes)?; - Component::new(&self.engine, wasm_sample) + Ok(Component::new(&self.engine, wasm_sample)?) } fn load_module(&self, _id: u64) -> anyhow::Result { - Module::new(&self.engine, DUMMY_SAMPLE) + Ok(Module::new(&self.engine, DUMMY_SAMPLE)?) } } @@ -492,7 +506,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -524,7 +538,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -583,7 +597,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -638,7 +652,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -666,7 +680,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -694,7 +708,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -722,7 +736,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -754,7 +768,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -788,7 +802,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -822,7 +836,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -852,7 +866,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -883,7 +897,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -915,7 +929,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -941,7 +955,7 @@ mod tests { Request::builder().method("GET").uri("/").body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); diff --git a/crates/http-service/src/executor/mod.rs b/crates/http-service/src/executor/mod.rs index 1c91073..3515575 100644 --- a/crates/http-service/src/executor/mod.rs +++ b/crates/http-service/src/executor/mod.rs @@ -12,7 +12,7 @@ use http_body_util::BodyExt; use hyper::body::Body; use runtime::{App, WasmEngine}; use smol_str::SmolStr; -use wasmtime_wasi_http::body::HyperOutgoingBody; +use wasmtime_wasi_http::p2::body::HyperOutgoingBody; pub use http::HttpExecutorImpl; pub use wasi_http::WasiHttpExecutorImpl; diff --git a/crates/http-service/src/executor/wasi_http.rs b/crates/http-service/src/executor/wasi_http.rs index 90a083c..5b16546 100644 --- a/crates/http-service/src/executor/wasi_http.rs +++ b/crates/http-service/src/executor/wasi_http.rs @@ -6,7 +6,7 @@ use crate::executor; use crate::executor::{HttpExecutor, X_CDN_REAL_HOST}; use crate::state::HttpState; use ::http::{HeaderMap, Request, Response, Uri, header}; -use anyhow::{Context, anyhow, bail}; +use anyhow::{Context, anyhow}; use async_trait::async_trait; use http_backend::Backend; use http_body_util::{BodyExt, Full}; @@ -15,9 +15,10 @@ use runtime::util::stats::{StatsTimer, StatsVisitor}; use runtime::{InstancePre, store::StoreBuilder}; use smol_str::SmolStr; use tracing::Instrument; -use wasmtime_wasi_http::bindings::ProxyPre; -use wasmtime_wasi_http::bindings::http::types::Scheme; -use wasmtime_wasi_http::{WasiHttpView, body::HyperOutgoingBody}; +use wasmtime_wasi_http::WasiHttpView; +use wasmtime_wasi_http::p2::bindings::ProxyPre; +use wasmtime_wasi_http::p2::bindings::http::types::Scheme; +use wasmtime_wasi_http::p2::body::HyperOutgoingBody; /// Execute context used by ['HttpService'] #[derive(Clone)] @@ -77,7 +78,7 @@ where .map_err(|_| anyhow!("body read error"))? .to_bytes(); let body = Full::new(body).map_err(|never| match never {}); - let body = body.boxed(); + let body: HyperOutgoingBody = body.boxed_unsync(); let properties = executor::get_properties(&parts.headers); let mut store_builder = self @@ -94,6 +95,13 @@ where } let mut http_backend = self.backend; http_backend.set_app_id(self.app_id); + if let Some(traceparent) = parts + .headers + .get(executor::TRACEPARENT) + .and_then(|v| v.to_str().ok()) + { + http_backend.set_traceparent(traceparent.into()); + } http_backend.epoch_exclude_http_wait(self.epoch_exclude_http_wait); if let Some(counter) = epoch_pause_ms { http_backend.set_epoch_pause_ms(counter); @@ -140,11 +148,15 @@ where let request = Request::from_parts(parts, body); let req = store .data_mut() + .http() .new_incoming_request(Scheme::Http, request) + .map_err(anyhow::Error::from) .context("new incoming request")?; let out = store .data_mut() + .http() .new_response_outparam(sender) + .map_err(anyhow::Error::from) .context("new response outparam")?; let proxy_pre = ProxyPre::new(instance_pre)?; @@ -155,9 +167,9 @@ where // declared minimum memory exceeds `mem_limit`) is recorded by the // limiter; classify it as out-of-memory instead of a generic error. if store.is_oom() { - return Err(runtime::store::OutOfMemory(error).into()); + return Err(runtime::store::OutOfMemory(error.into()).into()); } - return Err(error); + return Err(error.into()); } }; @@ -173,12 +185,15 @@ where ) .await { - Ok(inner) => inner, + // Convert into `anyhow::Error` here so the task's error type + // stays what `fail_reason_of`/`map_err` classify. The + // conversion rebuilds the context chain, so `root_cause()` + // still downcasts to `wasmtime::Trap`. + Ok(inner) => inner.map_err(anyhow::Error::from), // tokio timeout elapsed (outer deadline hit). Err(elapsed) => Err(elapsed.into()), }; if let Err(e) = exec_result { - tracing::warn!(cause=?e, "incoming handler"); // Record the failure reason on the shared stats. The response // headers may already have been flushed (the guest called // `response-outparam::set` before trapping mid-body), in which @@ -204,18 +219,49 @@ where match receiver.await { Ok(Ok(response)) => { + // The response headers are already on their way to the client, so a + // later failure of the task (e.g. an epoch interrupt while streaming + // the body) can no longer be propagated to the caller. This watcher + // is the only place it can be reported. + // + // It is spawned *only* on this path. On every other path the error + // still reaches the caller and is logged upstream, so watching here + // as well would log the same failure twice. + tokio::task::spawn( + async move { + match task.await { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::warn!(cause=?e, "incoming handler"), + // Panic or cancellation. Nothing else observes the task + // once headers are flushed, so report it here too. + Err(e) => tracing::warn!(cause=?e, "incoming handler task"), + } + } + .in_current_span(), + ); stats.status_code(response.status().as_u16()); Ok(response) } + // The guest set an error response, which is returned to the caller + // below - so the task must not log it as well. Dropping the handle only + // detaches the task: it still runs to completion and still records + // `fail_reason` on the shared stats. Ok(Err(error)) => Err(error.into()), Err(_) => { let e = match task.await { - Ok(r) => { - r.expect_err("if the receiver has an error, the task must have failed") - } + // The guest returned without ever setting the outparam, so there + // is no task error to attribute. Synthesise one rather than + // asserting the task failed, which would panic on this path. + Ok(Ok(())) => anyhow!("handler completed without setting a response"), + Ok(Err(e)) => e, Err(e) => e.into(), }; - bail!("guest never invoked `response-outparam::set` method: {e:?}") + // Attach context instead of formatting `e` into a new message so the + // source chain is preserved: `map_err`/`fail_reason_of` rely on + // `root_cause()` downcasting to `wasmtime::Trap` / `Elapsed` to + // classify timeouts. Flattening it into a string made every failure + // here look like a generic execute error (530 instead of 532). + Err(e).context("guest never invoked `response-outparam::set` method") } } } diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index ae21782..6e75a35 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -15,17 +15,19 @@ use http_backend::SERVER_NAME_HEADER; use http_body_util::{BodyExt, Empty, Full}; use hyper::{body::Body, server::conn::http1, service::service_fn}; use hyper_util::{client::legacy::connect::Connect, rt::TokioIo}; -#[cfg(feature = "metrics")] use runtime::util::metrics; use runtime::util::stats::StatsVisitor; use runtime::{ - App, AppResult, ContextT, Router, WasmEngine, WasmEngineBuilder, app::Status, service::Service, + App, AppResult, ContextT, Router, WasmEngine, WasmEngineBuilder, + access_log::{AccessLog, RequestKind}, + app::Status, + service::Service, }; use smol_str::{SmolStr, ToSmolStr}; use state::HttpState; use tokio::{net::TcpListener, time::error::Elapsed}; use tracing::Instrument; -pub use wasmtime_wasi_http::body::HyperOutgoingBody; +pub use wasmtime_wasi_http::p2::body::HyperOutgoingBody; pub mod executor; pub mod state; @@ -38,7 +40,6 @@ type OwnedFd = std::os::fd::OwnedFd; #[cfg(not(target_family = "unix"))] type OwnedFd = std::os::raw::c_int; -#[cfg(feature = "metrics")] const HTTP_LABEL: &[&str; 1] = &["http"]; const FASTEDGE_INTERNAL_ERROR: u16 = 530; @@ -220,9 +221,14 @@ where // Allow re-importing of `wasi:clocks/wall-clock@0.2.0` wasmtime_wasi::p2::add_to_linker_async(linker)?; linker.allow_shadowing(true); - wasmtime_wasi_http::add_to_linker_async(linker)?; + wasmtime_wasi_http::p2::add_to_linker_async(linker)?; wasmtime_wasi_nn::wit::add_to_linker(linker, |data: &mut runtime::Data<_>| { - WasiNnView::new(&mut data.table, &mut data.wasi_nn) + // Executors preload (`StoreBuilder::preload_wasi_nn`), so this is set; + // a store built without it (tests) gets an empty context on first use. + let wasi_nn = data + .wasi_nn + .get_or_insert_with(|| runtime::CachedGraphRegistry::ctx_for(&[])); + WasiNnView::new(&mut data.table, wasi_nn) })?; Ok(()) } @@ -260,24 +266,70 @@ where T::Executor: HttpExecutor + Send + Sync, S: StatsVisitor + Send + 'static, { - /// handle HTTP request. + /// handle HTTP request, emitting exactly one system request-log record for + /// every processing outcome (success, failure, or early rejection). async fn handle_request( &self, - mut request: hyper::Request, + request: hyper::Request, ) -> Result> where B: BodyExt + Send, ::Data: Send, { let traceparent = remote_traceparent(&request); + let mut log = AccessLog::new(traceparent.to_string(), RequestKind::Http); + log.method = Some(request.method().as_str().to_string()); + log.uri = Some(request.uri().to_string()); + + let result = self + .handle_request_inner(request, &traceparent, &mut log) + .await; + + match &result { + Ok(response) => { + log.status_code = response.status().as_u16(); + log.internal_status_code = response + .headers() + .get(X_CDN_INTERNAL_STATUS) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + } + Err(_) => { + log.status_code = FASTEDGE_INTERNAL_ERROR; + log.internal_status_code = INTERNAL_STATUS_EXECUTE_ERROR; + } + } + self.context.access_log_sender().log(log); + result + } + + async fn handle_request_inner( + &self, + mut request: hyper::Request, + traceparent: &SmolStr, + log: &mut AccessLog, + ) -> Result> + where + B: BodyExt + Send, + ::Data: Send, + { request .headers_mut() .extend(app_req_headers(self.context.append_headers())); + // Pin the resolved traceparent onto the request (it may have been + // generated when the client sent none) so the executor/backend logs + // outbound requests under the same id the access log uses. + if let Ok(value) = HeaderValue::from_str(traceparent) { + request + .headers_mut() + .insert(HeaderName::from_static(TRACEPARENT), value); + } + // get application name from request URL let app_name = match app_name_from_request(&request) { Err(error) => { - #[cfg(feature = "metrics")] metrics::metrics(AppResult::UNKNOWN, HTTP_LABEL, None, None); tracing::info!(cause=?error, traceparent = %traceparent, "App name not provided"); return not_found(); @@ -307,7 +359,6 @@ where let (app_name, cfg) = match lookup { None => { - #[cfg(feature = "metrics")] metrics::metrics(AppResult::UNKNOWN, HTTP_LABEL, None, None); tracing::info!("Request for unknown application on URL: {}", request.uri()); return not_found(); @@ -315,6 +366,7 @@ where Some((app_name, cfg)) if cfg.status == Status::Draft || cfg.status == Status::Disabled => { + metrics::metrics(AppResult::DISABLED, HTTP_LABEL, None, None); tracing::info!( "Request for disabled application '{}' on URL: {}", app_name, @@ -323,6 +375,7 @@ where return not_found(); } Some((app_name, cfg)) if cfg.status == Status::RateLimited => { + metrics::metrics(AppResult::RATE_LIMITED, HTTP_LABEL, None, None); tracing::info!( "Request for rate limited application '{}' on URL: {}", app_name, @@ -331,6 +384,7 @@ where return too_many_requests(); } Some((app_name, cfg)) if cfg.status == Status::Suspended => { + metrics::metrics(AppResult::SUSPENDED, HTTP_LABEL, None, None); tracing::info!( "Request for suspended application '{}' on URL: {}", app_name, @@ -342,6 +396,10 @@ where Some((app_name, cfg)) => (app_name, cfg), }; + log.app_id = Some(cfg.app_id); + log.client_id = Some(cfg.client_id); + log.app_name = Some(app_name.to_string()); + // get cached execute context for this application let executor = match self .context @@ -350,7 +408,6 @@ where { Ok(executor) => executor, Err(error) => { - #[cfg(feature = "metrics")] metrics::metrics(AppResult::UNKNOWN, HTTP_LABEL, None, None); tracing::warn!(cause=?error, app=%app_name, "failure on getting context" @@ -359,11 +416,25 @@ where } }; - let stats = self.context.new_stats_row(&traceparent, &app_name, &cfg); + let caller_ip = request + .headers() + .get(executor::X_REAL_IP) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(std::net::Ipv4Addr::UNSPECIFIED); + let stats = self + .context + .new_stats_row(traceparent, &app_name, caller_ip, &cfg); + if is_stats_excluded_user_agent( + self.context.stats_excluded_user_agent(), + request.headers(), + ) { + tracing::debug!("stats excluded by user agent"); + stats.discard(); + } let response = match executor.execute(request, stats.clone()).await { Ok(mut response) => { - #[cfg(feature = "metrics")] metrics::metrics( AppResult::SUCCESS, &["http"], @@ -381,7 +452,6 @@ where stats.fail_reason(fail_reason as i32); tracing::debug!(?fail_reason, ?traceparent, "stats"); - #[cfg(feature = "metrics")] metrics::metrics( fail_reason, HTTP_LABEL, @@ -400,6 +470,7 @@ where builder.body(msg)? } }; + log.duration = Some(Duration::from_micros(stats.get_time_elapsed())); Ok(response) } .instrument(span) @@ -407,6 +478,17 @@ where } } +/// `true` when `excluded` is set and the request's first `User-Agent` header +/// equals it byte for byte. Used to keep synthetic probes out of the stats store. +fn is_stats_excluded_user_agent(excluded: Option<&str>, headers: &HeaderMap) -> bool { + match excluded { + Some(ua) => headers + .get(http::header::USER_AGENT) + .is_some_and(|v| v.as_bytes() == ua.as_bytes()), + None => false, + } +} + /// Classify an execution error into an [`AppResult`] fail reason. /// /// Kept consistent with the fail-reason mapping in [`map_err`]. Used to record @@ -418,7 +500,7 @@ pub(crate) fn fail_reason_of(error: &Error) -> AppResult { let root_cause = error.root_cause(); if error.chain().any(|e| e.is::()) { AppResult::OOM - } else if let Some(exit) = root_cause.downcast_ref::() { + } else if let Some(exit) = root_cause.downcast_ref::() { if exit.0 == 0 { AppResult::SUCCESS } else { @@ -450,15 +532,15 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::OOM, Full::new(Bytes::from("fastedge: Out of memory")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_OUT_OF_MEMORY, ) - } else if let Some(exit) = root_cause.downcast_ref::() { + } else if let Some(exit) = root_cause.downcast_ref::() { if exit.0 == 0 { ( StatusCode::OK.as_u16(), AppResult::SUCCESS, - Empty::new().map_err(|never| match never {}).boxed(), + Empty::new().map_err(|never| match never {}).boxed_unsync(), 0, ) } else { @@ -467,7 +549,7 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::OTHER, Full::new(Bytes::from("fastedge: App failed")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_APP_EXIT_ERROR, ) } @@ -478,7 +560,7 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::TIMEOUT, Full::new(Bytes::from("fastedge: Execution timeout")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_TIMEOUT_INTERRUPT, ), wasmtime::Trap::UnreachableCodeReached => ( @@ -486,7 +568,7 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::OOM, Full::new(Bytes::from("fastedge: Out of memory")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_OUT_OF_MEMORY, ), _ => ( @@ -494,7 +576,7 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::OTHER, Full::new(Bytes::from("fastedge: App failed")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_WASM_TRAP_OTHER, ), } @@ -504,7 +586,7 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::TIMEOUT, Full::new(Bytes::from("fastedge: Execution timeout")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_TIMEOUT_ELAPSED, ) } else if root_cause.to_string().ends_with("deadline has elapsed") { @@ -513,7 +595,7 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::TIMEOUT, Full::new(Bytes::from("fastedge: Execution timeout")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_TIMEOUT_DEADLINE, ) } else { @@ -522,7 +604,7 @@ fn map_err(error: Error) -> (u16, AppResult, HyperOutgoingBody, u16) { AppResult::OTHER, Full::new(Bytes::from("fastedge: Execute error")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), INTERNAL_STATUS_EXECUTE_ERROR, ) }; @@ -533,8 +615,9 @@ fn remote_traceparent(req: &hyper::Request) -> SmolStr { req.headers() .get(TRACEPARENT) .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) .map(|s| s.to_smolstr()) - .unwrap_or(nanoid::nanoid!().to_smolstr()) + .unwrap_or_else(runtime::new_traceparent) } /// Creates an HTTP 530 response with an `X-CDN-Internal-Status` header. @@ -548,7 +631,7 @@ fn internal_fastedge_error( .body( Full::new(Bytes::from(format!("fastedge: {}", msg))) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), )?) } @@ -559,7 +642,7 @@ fn not_found() -> Result> { .body( Full::new(Bytes::from("fastedge: Unknown app")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), )?) } @@ -567,14 +650,14 @@ fn not_found() -> Result> { fn too_many_requests() -> Result> { Ok(hyper::Response::builder() .status(StatusCode::TOO_MANY_REQUESTS) - .body(Empty::new().map_err(|never| match never {}).boxed())?) + .body(Empty::new().map_err(|never| match never {}).boxed_unsync())?) } /// Creates an HTTP 406 response. fn not_acceptable() -> Result> { Ok(hyper::Response::builder() .status(StatusCode::NOT_ACCEPTABLE) - .body(Empty::new().map_err(|never| match never {}).boxed())?) + .body(Empty::new().map_err(|never| match never {}).boxed_unsync())?) } #[derive(Debug, Clone)] @@ -717,6 +800,25 @@ mod tests { http::Request::builder().method("GET") } + // ── Stats exclusion by User-Agent ───────────────────────────────────── + + #[test_case(Some("scanner/1.0"), Some("scanner/1.0"), true; "exact match is excluded")] + #[test_case(Some("scanner/1.0"), Some("Scanner/1.0"), false; "case differs: not excluded")] + #[test_case(Some("scanner/1.0"), Some("scanner/1.0 "), false; "trailing space: not excluded")] + #[test_case(Some("scanner/1.0"), Some("x scanner/1.0"), false; "substring: not excluded")] + #[test_case(Some("scanner/1.0"), None, false; "no user agent: not excluded")] + #[test_case(None, Some("scanner/1.0"), false; "filter disabled: not excluded")] + fn stats_excluded_user_agent(excluded: Option<&str>, ua: Option<&str>, expected: bool) { + let mut headers = http::HeaderMap::new(); + if let Some(ua) = ua { + headers.insert(http::header::USER_AGENT, ua.parse().unwrap()); + } + assert_eq!( + crate::is_stats_excluded_user_agent(excluded, &headers), + expected + ); + } + // ── Name variant: server_name header ────────────────────────────────── #[test_case("app.server.com", "/", "app"; "server_name: normal subdomain")] @@ -729,7 +831,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -746,7 +848,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -763,7 +865,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -780,7 +882,7 @@ mod tests { empty_body_request().uri(uri).body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -798,7 +900,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -816,7 +918,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -832,7 +934,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); assert_err!(app_name_from_request(&req)); @@ -846,7 +948,7 @@ mod tests { empty_body_request().uri("/").body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); assert_err!(app_name_from_request(&req)); @@ -865,4 +967,25 @@ mod tests { let id = AppName::Id(1234); assert_eq!("1234", id.to_string()); } + + // ── Error classification ───────────────────────────────────────────── + + /// A timeout trap must stay classified as a timeout even when the + /// `wasi_http` executor wraps it with `guest never invoked + /// `response-outparam::set``. Formatting the cause into the message instead + /// of chaining it loses the `Trap` downcast and yields 530 instead of 532. + #[test] + fn test_map_err_keeps_timeout_through_context() { + use anyhow::Context; + + let inner = anyhow::Error::from(wasmtime::Trap::Interrupt).context("error while executing"); + let wrapped = Err::<(), _>(inner) + .context("guest never invoked `response-outparam::set` method") + .unwrap_err(); + + let (status_code, fail_reason, _msg, internal_code) = crate::map_err(wrapped); + assert_eq!(crate::FASTEDGE_EXECUTION_TIMEOUT, status_code); + assert_eq!(runtime::AppResult::TIMEOUT as i32, fail_reason as i32); + assert_eq!(crate::INTERNAL_STATUS_TIMEOUT_INTERRUPT, internal_code); + } } diff --git a/crates/http-service/src/state.rs b/crates/http-service/src/state.rs index 97815ef..944ded3 100644 --- a/crates/http-service/src/state.rs +++ b/crates/http-service/src/state.rs @@ -3,6 +3,7 @@ use http::request::Parts; use http::uri::Scheme; use http::{HeaderMap, HeaderName, HeaderValue, Uri, header}; use http_backend::Backend; +use http_backend::access_log::ExtRequestLogHandle; use http_backend::{is_backend_host, is_fastedge_internal_header, is_public_host}; use runtime::BackendRequest; use runtime::store::HasStats; @@ -31,6 +32,10 @@ const FASTEDGE_APP_ID: HeaderName = HeaderName::from_static("fastedge-app-id"); const CDN_LOOP: HeaderName = HeaderName::from_static("cdn-loop"); impl BackendRequest for HttpState { + fn ext_request_log_handle(&self) -> Option { + self.http_backend.ext_request_log_handle() + } + #[instrument(skip(self, head), level = "debug", ret)] fn backend_request(&mut self, mut head: Parts) -> anyhow::Result { match self.http_backend.strategy { @@ -236,6 +241,7 @@ mod tests { impl ReadStats for TestStats { fn count_kv_read(&self, _value: i32) {} fn count_kv_byod_read(&self, _value: i32) {} + fn count_kv_read_cached(&self) {} } impl UserDiagStats for TestStats { fn set_user_diag(&self, _diag: &str) {} diff --git a/crates/key-value-store/Cargo.toml b/crates/key-value-store/Cargo.toml index 5b5f144..168ad06 100644 --- a/crates/key-value-store/Cargo.toml +++ b/crates/key-value-store/Cargo.toml @@ -8,16 +8,17 @@ description = "key-value store host function" [features] default = [] -redis = ["dep:redis"] +redis = ["dep:redis", "dep:tokio"] [dependencies] reactor = { path = "../reactor" } -wasmtime = {workspace = true} +wasmtime = { workspace = true } slab = "0.4" async-trait = "0.1" -smol_str = {workspace = true} +smol_str = { workspace = true } tracing = "0.1" -redis = { version = "1.2", features = ["aio", "tokio-comp", "connection-manager", "tokio-native-tls-comp"], optional = true} +tokio = { workspace = true, optional = true } +redis = { version = "1.2", features = ["aio", "tokio-comp", "connection-manager", "tokio-native-tls-comp"], optional = true } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/key-value-store/src/lib.rs b/crates/key-value-store/src/lib.rs index 05d4336..745be29 100644 --- a/crates/key-value-store/src/lib.rs +++ b/crates/key-value-store/src/lib.rs @@ -5,6 +5,7 @@ use reactor::gcore::fastedge::key_value; use slab::Slab; use smol_str::SmolStr; use std::sync::Arc; +use std::sync::OnceLock; use tracing::instrument; use wasmtime::component::Resource; @@ -13,10 +14,28 @@ pub use key_value::{Error, Value}; #[cfg(feature = "redis")] pub use redis_impl::RedisStore; +/// Outcome of a [`Store::get_tracked`] call: the value plus where it came from. +pub struct GetOutcome { + pub value: Option, + /// `true` when the value was served from an in-process cache instead of + /// the backing store. + pub from_cache: bool, +} + #[async_trait::async_trait] pub trait Store: Sync + Send { async fn get(&self, key: &str) -> Result, Error>; + /// Like [`Store::get`], but also reports whether the value was served from + /// an in-process cache. Layers that do not cache inherit this default, + /// which delegates to [`Store::get`] and reports a backing-store read. + async fn get_tracked(&self, key: &str) -> Result { + Ok(GetOutcome { + value: self.get(key).await?, + from_cache: false, + }) + } + async fn zrange_by_score( &self, key: &str, @@ -41,12 +60,47 @@ pub trait StoreManager: Sync + Send { ) -> Result, Error>; } +/// Command label passed to a [`ReadRetryObserver`]: `get` / `get_tracked`. +pub const CMD_GET: &str = "get"; +/// Command label passed to a [`ReadRetryObserver`]: `zrange_by_score`. +pub const CMD_ZRANGE_BY_SCORE: &str = "zrange_by_score"; +/// Command label passed to a [`ReadRetryObserver`]: `scan`. +pub const CMD_SCAN: &str = "scan"; +/// Command label passed to a [`ReadRetryObserver`]: `zscan`. +pub const CMD_ZSCAN: &str = "zscan"; +/// Command label passed to a [`ReadRetryObserver`]: `bf_exists`. +pub const CMD_BF_EXISTS: &str = "bf_exists"; + +/// Called once per retried backing-store read attempt (the retry itself, not +/// the first attempt), with the command label that was retried. +pub type ReadRetryObserver = fn(command: &'static str); + +static READ_RETRY_OBSERVER: OnceLock = OnceLock::new(); + +/// Install the process-wide read-retry observer. Lets an embedder count retries +/// (e.g. into a Prometheus counter) without this crate depending on a metrics +/// backend. Returns `false` if an observer was already installed. +pub fn set_read_retry_observer(observer: ReadRetryObserver) -> bool { + READ_RETRY_OBSERVER.set(observer).is_ok() +} + +/// Report one retried read attempt to the installed observer, if any. +pub fn note_read_retry(command: &'static str) { + if let Some(observer) = READ_RETRY_OBSERVER.get() { + observer(command); + } +} + /// Key-Value store read metrics pub trait ReadStats: Sync + Send { /// Increment key-value read count and size fn count_kv_read(&self, value: i32); /// Increment key-value read count and size for BYOD fn count_kv_byod_read(&self, value: i32); + /// Increment the count of reads served from the in-process cache. Counted + /// in addition to [`ReadStats::count_kv_read`] / + /// [`ReadStats::count_kv_byod_read`], so it is a subset of the total reads. + fn count_kv_read_cached(&self); } #[derive(Clone)] @@ -242,6 +296,27 @@ mod tests { use std::collections::HashMap; use std::sync::atomic::AtomicI32; + static OBSERVED_RETRIES: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + + #[test] + fn read_retry_observer_receives_retries() { + fn observer(command: &'static str) { + assert_eq!(command, CMD_GET); + OBSERVED_RETRIES.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + assert!(set_read_retry_observer(observer), "first install wins"); + // A second install is rejected rather than silently swapping the hook. + assert!(!set_read_retry_observer(observer)); + + note_read_retry(CMD_GET); + note_read_retry(CMD_GET); + assert_eq!( + OBSERVED_RETRIES.load(std::sync::atomic::Ordering::SeqCst), + 2 + ); + } + // Mock implementation of Store struct MockStore { data: HashMap, @@ -367,6 +442,8 @@ mod tests { self.byod_reads .fetch_add(value, std::sync::atomic::Ordering::Relaxed); } + + fn count_kv_read_cached(&self) {} } // Mock implementation of StoreManager diff --git a/crates/key-value-store/src/redis_impl.rs b/crates/key-value-store/src/redis_impl.rs index e564100..92c5bcb 100644 --- a/crates/key-value-store/src/redis_impl.rs +++ b/crates/key-value-store/src/redis_impl.rs @@ -1,7 +1,7 @@ use crate::Store; use reactor::gcore::fastedge::key_value::{Error, Value}; use redis::aio::{ConnectionManager, ConnectionManagerConfig}; -use redis::{AsyncCommands, AsyncIter}; +use redis::{AsyncCommands, AsyncIter, RedisError, RetryMethod}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; @@ -16,6 +16,137 @@ const REDIS_CONNECTION_TIMEOUT: Duration = Duration::from_millis(100); const REDIS_NUMBER_OF_RETRIES: usize = 2; const REDIS_MAX_RECONNECT_DELAY: Duration = Duration::from_millis(100); +/// Retry budget for a single read command (on top of connection-manager +/// reconnection above). Kept small and fixed for the same reason as the +/// timeouts: Redis is on the request hot path, so a retry loop must have a +/// bounded worst case rather than let a stalled dependency pile up requests. +const REDIS_READ_RETRIES: usize = 2; +const REDIS_READ_RETRY_BASE_DELAY: Duration = Duration::from_millis(10); +const REDIS_READ_RETRY_MAX_DELAY: Duration = Duration::from_millis(50); + +/// Backoff applied between attempts when the server asked us to wait. +/// Saturating throughout, so a larger retry budget can never overflow the +/// shift or the multiplication into a panic. +fn redis_read_retry_delay(attempt: usize) -> Duration { + let scale = 1u32 + .checked_shl(attempt.saturating_sub(1) as u32) + .unwrap_or(u32::MAX); + REDIS_READ_RETRY_BASE_DELAY + .saturating_mul(scale) + .min(REDIS_READ_RETRY_MAX_DELAY) +} + +/// How long to wait before re-attempting a failed read, or `None` when the +/// failure must not be retried at all. `attempt` is the 1-based number of the +/// attempt about to be made, used to scale the backoff. +/// +/// Allowlist rather than denylist: [`RetryMethod`] is `#[non_exhaustive]`, so a +/// redis-crate bump can introduce a variant, and defaulting those to "retry" +/// would silently spend the budget on failures we have never classified. +/// +/// The cluster redirect methods (`MovedRedirect`, `AskRedirect`, +/// `RefreshSlotsAndRetry`, `ReconnectFromInitialConnections`) are deliberately +/// excluded: this store speaks to Redis through a plain `ConnectionManager`, +/// not the cluster API, so nothing here can follow a redirect — a bare retry +/// would just re-send to the same node and collect the same error. +fn retry_backoff(error: &RedisError, attempt: usize) -> Option { + match error.retry_method() { + // Nothing to back off from: the command never reached a working + // server, so there is no load to relieve and sleeping would only add + // latency to the request. `Reconnect` means the socket is gone or the + // stream desynced — re-running the op picks the next pooled connection + // while `ConnectionManager` re-establishes its own socket underneath + // (with its own capped backoff), so the next attempt already gets a + // fresh channel. + RetryMethod::RetryImmediately | RetryMethod::Reconnect => Some(Duration::ZERO), + // The server is reachable but told us it cannot serve yet (`LOADING`, + // `TRYAGAIN`). Here the pause is the point: it keeps a struggling node + // from being hammered by every in-flight request at once. + RetryMethod::WaitAndRetry => Some(redis_read_retry_delay(attempt)), + _ => None, + } +} + +/// Whether the server rejected the command in a way that can never succeed on +/// retry, because the guest's own request was at fault — e.g. `WRONGTYPE` when +/// `get` addresses a key holding a sorted set, or `NOPERM`/`ERR` for a command +/// the guest may not run. +/// +/// Such a rejection says nothing about node or Redis health, so it must not +/// raise a node-level warning; it is handed back to the guest instead. +/// +/// Distinct from `!should_retry`: a `MOVED` reply or a failed authentication is +/// equally pointless to retry, but it is the node's problem, not the app's, so +/// it stays an opaque internal error and still warns. +/// +/// Only failures the server itself answered are classified: [`RedisError::code`] +/// is `None` for I/O, timeout and client-config failures. +fn is_guest_fault(error: &RedisError) -> bool { + matches!(error.retry_method(), RetryMethod::NoRetry) && error.code().is_some() +} + +/// The server's own code and detail, for handing back to the guest. +fn guest_fault_message(error: &RedisError) -> String { + let code = error.code().unwrap_or_default(); + match error.detail() { + Some(detail) => format!("{code}: {detail}"), + None => code.to_string(), + } +} + +/// Map a failed read onto the error the guest sees. +/// +/// A guest-caused rejection is returned verbatim as [`Error::Other`] so the app +/// can fix its request, and is only traced at debug level. Anything else is +/// opaque to the guest and is reported by `log_unexpected`, which each caller +/// supplies so the warning keeps that command's own fields. +fn read_error(error: RedisError, log_unexpected: impl FnOnce(&RedisError)) -> Error { + if is_guest_fault(&error) { + tracing::debug!(cause = ?error, "kv-store: redis rejected read"); + return Error::Other(guest_fault_message(&error)); + } + log_unexpected(&error); + Error::InternalError +} + +/// Retry a read command up to `REDIS_READ_RETRIES` times with capped +/// exponential backoff. `op` is re-invoked from scratch on each attempt so it +/// can pick a fresh pooled connection (used by `get`/`zrange_by_score`/ +/// `bf_exists`; `scan`/`zscan` retry the same connection since their iterator +/// borrows it across the loop). Each retry is reported to the installed +/// [`crate::ReadRetryObserver`] under `command`. +/// +/// Only failures [`retry_backoff`] accepts are attempted again, and only those +/// that ask for it sleep first; deterministic failures (a `WRONGTYPE` key, a +/// `MOVED` slot) return on the first error rather than spending the budget on a +/// result that cannot change. +async fn retry_read(command: &'static str, mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut attempt = 0; + loop { + match op().await { + Ok(value) => return Ok(value), + Err(error) => { + if attempt >= REDIS_READ_RETRIES { + return Err(error); + } + let Some(delay) = retry_backoff(&error, attempt + 1) else { + return Err(error); + }; + attempt += 1; + crate::note_read_retry(command); + tracing::debug!(attempt, cause = ?error, "kv-store: redis read retry"); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + } + } + } +} + /// Build the fail-fast connection-manager config for KV-store Redis connections. fn connection_manager_config() -> ConnectionManagerConfig { ConnectionManagerConfig::new() @@ -78,10 +209,13 @@ impl RedisStore { #[async_trait::async_trait] impl Store for RedisStore { async fn get(&self, key: &str) -> Result, Error> { - self.conn().get(key).await.map_err(|error| { - tracing::warn!(cause = ?error, key, "kv-store: redis get"); - Error::InternalError - }) + retry_read(crate::CMD_GET, || async { self.conn().get(key).await }) + .await + .map_err(|error| { + read_error(error, |error| { + tracing::warn!(cause = ?error, key, "kv-store: redis get"); + }) + }) } async fn zrange_by_score( @@ -90,26 +224,49 @@ impl Store for RedisStore { min: f64, max: f64, ) -> Result, Error> { - self.conn() - .zrangebyscore_withscores(key, min, max) - .await - .map_err(|error| { + retry_read(crate::CMD_ZRANGE_BY_SCORE, || async { + self.conn().zrangebyscore_withscores(key, min, max).await + }) + .await + .map_err(|error| { + read_error(error, |error| { tracing::warn!(cause = ?error, key, min, max, "kv-store: redis zrangebyscore"); - Error::InternalError }) + }) } async fn scan(&self, pattern: &str) -> Result, Error> { let mut conn = self.conn(); - let mut it = conn.scan_match(pattern).await.map_err(|error| { - tracing::warn!(cause = ?error, pattern, "kv-store: redis scan_match"); - Error::InternalError - })?; + let mut attempt = 0; + let mut it = loop { + match conn.scan_match(pattern).await { + Ok(it) => break it, + Err(error) => { + let backoff = if attempt < REDIS_READ_RETRIES { + retry_backoff(&error, attempt + 1) + } else { + None + }; + let Some(delay) = backoff else { + return Err(read_error(error, |error| { + tracing::warn!(cause = ?error, pattern, "kv-store: redis scan_match"); + })); + }; + attempt += 1; + crate::note_read_retry(crate::CMD_SCAN); + tracing::debug!(attempt, cause = ?error, pattern, "kv-store: redis scan_match retry"); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + } + } + }; let mut ret = vec![]; while let Some(element) = it.next_item().await { ret.push(element.map_err(|error| { - tracing::warn!(cause = ?error, pattern, "kv-store: redis scan_match: item"); - Error::Other(error.to_string()) + read_error(error, |error| { + tracing::warn!(cause = ?error, pattern, "kv-store: redis scan_match: item"); + }) })?); } Ok(ret) @@ -117,30 +274,264 @@ impl Store for RedisStore { async fn zscan(&self, key: &str, pattern: &str) -> Result, Error> { let mut conn = self.conn(); - let mut it: AsyncIter<(Value, f64)> = - conn.zscan_match(key, pattern).await.map_err(|error| { - tracing::warn!(cause = ?error, key, pattern, "kv-store: redis zscan_match"); - Error::InternalError - })?; + let mut attempt = 0; + let mut it: AsyncIter<(Value, f64)> = loop { + match conn.zscan_match(key, pattern).await { + Ok(it) => break it, + Err(error) => { + let backoff = if attempt < REDIS_READ_RETRIES { + retry_backoff(&error, attempt + 1) + } else { + None + }; + let Some(delay) = backoff else { + return Err(read_error(error, |error| { + tracing::warn!(cause = ?error, key, pattern, "kv-store: redis zscan_match"); + })); + }; + attempt += 1; + crate::note_read_retry(crate::CMD_ZSCAN); + tracing::debug!(attempt, cause = ?error, key, pattern, "kv-store: redis zscan_match retry"); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + } + } + }; let mut ret = vec![]; while let Some(element) = it.next_item().await { ret.push(element.map_err(|error| { - tracing::warn!(cause = ?error, key, pattern, "kv-store: redis zscan_match: item"); - Error::Other(error.to_string()) + read_error(error, |error| { + tracing::warn!(cause = ?error, key, pattern, "kv-store: redis zscan_match: item"); + }) })?); } Ok(ret) } async fn bf_exists(&self, key: &str, item: &str) -> Result { - redis::cmd("BF.EXISTS") - .arg(key) - .arg(item) - .query_async(&mut self.conn()) - .await - .map_err(|error| { + retry_read(crate::CMD_BF_EXISTS, || async { + redis::cmd("BF.EXISTS") + .arg(key) + .arg(item) + .query_async(&mut self.conn()) + .await + }) + .await + .map_err(|error| { + read_error(error, |error| { tracing::warn!(cause = ?error, key, item, "kv-store: redis bf_exists"); - Error::InternalError }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + + /// Build a real `RedisError` by parsing a RESP error frame, so the tests + /// exercise the same classification path as a live server reply. + fn server_error(frame: &str) -> RedisError { + match ::redis::parse_redis_value(frame.as_bytes()) { + Ok(::redis::Value::ServerError(error)) => error.into(), + other => panic!("RESP error frame must parse as a server error: {other:?}"), + } + } + + #[test] + fn wrongtype_is_a_guest_fault() { + let error = + server_error("-WRONGTYPE Operation against a key holding the wrong kind of value\r\n"); + assert!(is_guest_fault(&error)); + assert_eq!( + guest_fault_message(&error), + "WRONGTYPE: Operation against a key holding the wrong kind of value" + ); + } + + #[test] + fn other_permanent_server_rejections_are_guest_faults() { + for frame in [ + "-ERR wrong number of arguments for 'get' command\r\n", + "-NOPERM this user has no permissions to run the 'get' command\r\n", + ] { + assert!(is_guest_fault(&server_error(frame)), "frame: {frame}"); + } + } + + #[test] + fn retriable_server_errors_are_not_guest_faults() { + // LOADING/TRYAGAIN are transient: the same command can succeed later, + // so they must keep retrying and keep warning. + for frame in [ + "-LOADING Redis is loading the dataset in memory\r\n", + "-TRYAGAIN Multiple keys request during rehashing of slot\r\n", + ] { + let error = server_error(frame); + assert!(!is_guest_fault(&error), "frame: {frame}"); + // The server asked us to wait, so the pause is the point. + assert_eq!( + retry_backoff(&error, 1), + Some(REDIS_READ_RETRY_BASE_DELAY), + "frame: {frame}" + ); + } + } + + #[test] + fn deterministic_errors_are_not_retried() { + for frame in [ + "-WRONGTYPE Operation against a key holding the wrong kind of value\r\n", + "-ERR wrong number of arguments for 'get' command\r\n", + "-NOPERM this user has no permissions to run the 'get' command\r\n", + // Cluster redirects: this store uses a plain (non-cluster) client, + // so a bare retry can only re-collect the same reply. + "-MOVED 3999 127.0.0.1:6381\r\n", + "-ASK 3999 127.0.0.1:6381\r\n", + ] { + assert_eq!( + retry_backoff(&server_error(frame), 1), + None, + "frame: {frame}" + ); + } + } + + #[test] + fn cluster_redirects_are_node_problems_not_guest_faults() { + // Not retriable, but the app did nothing wrong: must stay opaque and + // still warn, unlike WRONGTYPE. + let error = server_error("-MOVED 3999 127.0.0.1:6381\r\n"); + assert_eq!(retry_backoff(&error, 1), None); + assert!(!is_guest_fault(&error)); + + let mut warned = false; + let mapped = read_error(error, |_| warned = true); + assert!(warned); + assert!(matches!(mapped, Error::InternalError)); + } + + #[test] + fn connection_errors_are_retried_but_are_not_guest_faults() { + // No server reply at all, so there is no code to hand back. + let error = RedisError::from(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); + assert!(!is_guest_fault(&error)); + // A dead socket is not congestion: reconnect and re-issue at once + // rather than adding latency to a request already on the hot path. + assert_eq!(retry_backoff(&error, 1), Some(Duration::ZERO)); + } + + #[test] + fn wait_and_retry_backoff_grows_and_is_capped() { + let error = server_error("-LOADING Redis is loading the dataset in memory\r\n"); + assert_eq!(retry_backoff(&error, 1), Some(REDIS_READ_RETRY_BASE_DELAY)); + assert_eq!( + retry_backoff(&error, 2), + Some(REDIS_READ_RETRY_BASE_DELAY * 2) + ); + assert_eq!(retry_backoff(&error, 99), Some(REDIS_READ_RETRY_MAX_DELAY)); + } + + #[test] + fn guest_fault_is_returned_verbatim_without_warning() { + let error = + server_error("-WRONGTYPE Operation against a key holding the wrong kind of value\r\n"); + let mut warned = false; + let mapped = read_error(error, |_| warned = true); + + assert!(!warned, "a guest-caused rejection must not warn"); + assert!( + matches!(mapped, Error::Other(message) if message.starts_with("WRONGTYPE: ")), + "guest must receive the server's own message" + ); + } + + #[test] + fn unexpected_error_warns_and_stays_opaque() { + let error = RedisError::from(std::io::Error::from(std::io::ErrorKind::ConnectionReset)); + let mut warned = false; + let mapped = read_error(error, |_| warned = true); + + assert!(warned); + assert!(matches!(mapped, Error::InternalError)); + } + + #[tokio::test] + async fn retry_read_does_not_retry_a_guest_fault() { + let attempts = AtomicUsize::new(0); + let result: Result<(), _> = retry_read(crate::CMD_GET, || async { + attempts.fetch_add(1, Ordering::Relaxed); + Err(server_error( + "-WRONGTYPE Operation against a key holding the wrong kind of value\r\n", + )) + }) + .await; + + assert!(result.is_err()); + assert_eq!( + attempts.load(Ordering::Relaxed), + 1, + "a deterministic rejection must not burn the retry budget" + ); + } + + #[tokio::test] + async fn retry_read_does_not_retry_a_cluster_redirect() { + let attempts = AtomicUsize::new(0); + let result: Result<(), _> = retry_read(crate::CMD_GET, || async { + attempts.fetch_add(1, Ordering::Relaxed); + Err(server_error("-MOVED 3999 127.0.0.1:6381\r\n")) + }) + .await; + + assert!(result.is_err()); + assert_eq!(attempts.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn retry_read_still_retries_transient_errors() { + let attempts = AtomicUsize::new(0); + let started = std::time::Instant::now(); + let result: Result<(), _> = retry_read(crate::CMD_GET, || async { + attempts.fetch_add(1, Ordering::Relaxed); + Err(RedisError::from(std::io::Error::from( + std::io::ErrorKind::ConnectionReset, + ))) + }) + .await; + + assert!(result.is_err()); + assert_eq!( + attempts.load(Ordering::Relaxed), + REDIS_READ_RETRIES + 1, + "transient failures must still use the full retry budget" + ); + assert!( + started.elapsed() < REDIS_READ_RETRY_BASE_DELAY, + "a dead socket must be re-issued without sleeping, took {:?}", + started.elapsed() + ); + } + + #[tokio::test] + async fn retry_read_sleeps_only_when_the_server_asks_to_wait() { + let attempts = AtomicUsize::new(0); + let started = std::time::Instant::now(); + let result: Result<(), _> = retry_read(crate::CMD_GET, || async { + attempts.fetch_add(1, Ordering::Relaxed); + Err(server_error( + "-LOADING Redis is loading the dataset in memory\r\n", + )) + }) + .await; + + assert!(result.is_err()); + assert_eq!(attempts.load(Ordering::Relaxed), REDIS_READ_RETRIES + 1); + assert!( + started.elapsed() >= REDIS_READ_RETRY_BASE_DELAY, + "a loading node must be given time to recover" + ); } } diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index d65243c..89d404f 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -5,16 +5,12 @@ edition.workspace = true publish.workspace = true authors.workspace = true -[features] -metrics = ["prometheus", "lazy_static"] - [dependencies] anyhow = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } -wasi-common = { workspace = true } wasmtime-wasi-nn = { workspace = true } wasmtime-wasi-http = { workspace = true } wasmtime-environ = { workspace = true } @@ -22,6 +18,8 @@ wasmtime-wasi-io = { workspace = true } smol_str = { workspace = true } moka = { workspace = true } http = { workspace = true } +http-body = "1" +http-body-util = "0.1" wit-component = "0.246.2" tracing = { workspace = true } bytesize = { workspace = true } @@ -32,15 +30,15 @@ utils = { path = "../utils" } secret = { path = "../secret" } async-trait = "0.1" bytes = "1.11" +rand = "0.8" serde = "1.0" serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } -prometheus = { version = "0.14.0", features = ["process"], optional = true } -lazy_static = { version = "1.5.0", optional = true } [dev-dependencies] claims = "0.8" wat = "1" +tempfile = "3" [lints] workspace = true diff --git a/crates/runtime/src/access_log.rs b/crates/runtime/src/access_log.rs new file mode 100644 index 0000000..0ab6d4b --- /dev/null +++ b/crates/runtime/src/access_log.rs @@ -0,0 +1,170 @@ +//! Per-request system log. +//! +//! This is **not** the guest application log (that ships the WASM app's stdout +//! via [`crate::logger`]). This log emits exactly one record per processed +//! request describing how the edge server handled it — traceparent, final HTTP +//! status, the `X-CDN-Internal-Status` diagnostic code, timing, etc. — for both +//! successful and failed requests, across every execution path (ProxyWasm, +//! FastEdge http-handler, and standard wasi:http). +//! +//! The transport is intentionally abstracted behind [`AccessLogSender`]. The +//! concrete appender (planned: a system log shipped over UDP with a JSON +//! payload) is implemented separately; until one is wired in, the runtime uses +//! [`NoopAccessLogSender`]. + +use std::time::Duration; + +/// Which execution path served the request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestKind { + /// ProxyWasm protocol (v1/v2/v2a) — WASI Preview1 core modules. + ProxyWasm, + /// HTTP request served by a Preview2 component — both the FastEdge + /// `gcore:fastedge/http-handler` export and the standard + /// `wasi:http/incoming-handler` export are reported under this variant. + Http, + /// Outbound HTTP request the guest made while serving a request (ProxyWasm + /// `proxy_http_call` or the WASI/http-handler fetch client). Emitted as its + /// own record, correlated to the inbound request via `traceparent`. + ExtHttp, +} + +impl RequestKind { + /// Stable lowercase identifier, suitable for a log field. + pub fn as_str(&self) -> &'static str { + match self { + RequestKind::ProxyWasm => "cdn", + RequestKind::Http => "http", + RequestKind::ExtHttp => "fetch", + } + } +} + +/// ProxyWasm lifecycle callback ("trigger") that produced this record. +/// +/// A single ProxyWasm request is driven through several host callbacks; each +/// one is handled — and logged — independently. Only set on the ProxyWasm path; +/// the HTTP paths run a single execution and leave it `None`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Trigger { + RequestHeaders, + ResponseHeaders, + RequestBody, + ResponseBody, + Log, +} + +impl Trigger { + /// Short, stable identifier for the ProxyWasm callback (kept compact to + /// minimise datagram size): `rqh`/`rsh` = request/response headers, + /// `rqb`/`rsb` = request/response body, `log` = on_log. + pub fn as_str(&self) -> &'static str { + match self { + Trigger::RequestHeaders => "rqh", + Trigger::ResponseHeaders => "rsh", + Trigger::RequestBody => "rqb", + Trigger::ResponseBody => "rsb", + Trigger::Log => "log", + } + } +} + +/// Upstream service that initiated a ProxyWasm request. Derived from the +/// ProxyWasm protocol version: nginx speaks v1/v2a/v2b and the v3 yamux +/// dialect, core-proxy speaks v2/v2c/v2d. Only set on the ProxyWasm path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Initiator { + Nginx, + CoreProxy, +} + +impl Initiator { + /// Stable identifier, suitable for a log field. + pub fn as_str(&self) -> &'static str { + match self { + Initiator::Nginx => "nginx", + Initiator::CoreProxy => "core-proxy", + } + } +} + +/// One log record describing the processing of a single request. +/// +/// `traceparent` and `status_code` are the primary fields; the rest are +/// best-effort context that may be absent on early-return paths (e.g. an +/// unknown application is rejected before its config — and thus its ids — are +/// known). +#[derive(Debug, Clone)] +pub struct AccessLog { + /// W3C trace-context id used to correlate the request across systems. + pub traceparent: String, + /// Execution path that handled the request. + pub kind: RequestKind, + /// Final HTTP status code returned to the client. + pub status_code: u16, + /// `X-CDN-Internal-Status` diagnostic code (range 3000–3999); `0` when the + /// request completed without an internal error. + pub internal_status_code: u16, + /// Edge application id, once resolved. + pub app_id: Option, + /// Edge application name, once resolved. + pub app_name: Option, + /// Owning customer id, once resolved. + pub client_id: Option, + /// Request HTTP method, when cheaply available. + pub method: Option, + /// Request URI, when cheaply available. + pub uri: Option, + /// Wall-clock time spent executing the WASM app, when the app ran. + pub duration: Option, + /// ProxyWasm lifecycle callback that produced this record (ProxyWasm only). + pub trigger: Option, + /// Upstream service that initiated the request (ProxyWasm only). + pub initiator: Option, +} + +impl AccessLog { + /// Start a record for a request of the given `kind`, keyed by `traceparent`. + /// Remaining fields are filled in as they become known during processing. + pub fn new(traceparent: String, kind: RequestKind) -> Self { + Self { + traceparent, + kind, + status_code: 0, + internal_status_code: 0, + app_id: None, + app_name: None, + client_id: None, + method: None, + uri: None, + duration: None, + trigger: None, + initiator: None, + } + } + + /// A request is considered successful when no internal error code was set + /// (client errors such as 404/429 are not internal failures). + pub fn success(&self) -> bool { + self.internal_status_code == 0 + } +} + +/// Sink for [`AccessLog`] records. +/// +/// Implementations must be non-blocking / fire-and-forget: `log` is called on +/// the request hot path and must not await or block on I/O. The planned UDP +/// appender simply sends a datagram (or hands off to a background task). +pub trait AccessLogSender: Send + Sync { + /// Record one completed request. Must not block. + fn log(&self, record: AccessLog); +} + +/// A [`AccessLogSender`] that discards every record. Used as the default until +/// a concrete appender is configured. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopAccessLogSender; + +impl AccessLogSender for NoopAccessLogSender { + fn log(&self, _record: AccessLog) {} +} diff --git a/crates/runtime/src/adapters/wasi_snapshot_preview1.reactor.wasm b/crates/runtime/src/adapters/wasi_snapshot_preview1.reactor.wasm index 89b68cf..ed39fa8 100644 Binary files a/crates/runtime/src/adapters/wasi_snapshot_preview1.reactor.wasm and b/crates/runtime/src/adapters/wasi_snapshot_preview1.reactor.wasm differ diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index ee5acde..e25e4f4 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -44,6 +44,10 @@ pub struct KvStoreOption { pub name: SmolStr, #[serde(default)] pub prefix: SmolStr, + /// Maximum **bytes** held by this store's in-process value cache (keys, + /// values and per-entry overhead), not a number of entries: entry count + /// cannot bound memory, since the same 25 000 entries are 2 MiB of + /// 64-byte values or 200 MiB of 8 KiB ones. #[serde(default = "KvStoreOption::default_cache_size")] pub cache_size: u64, #[serde(default = "KvStoreOption::default_cache_ttl")] @@ -51,8 +55,11 @@ pub struct KvStoreOption { } impl KvStoreOption { + /// 10 MiB. Was 25 000 *entries* until 2026-09-15; the unit changed with + /// the cache's weigher, and the field was set nowhere in prod or preprod, + /// so every store simply takes this default. fn default_cache_size() -> u64 { - 25000 + 10 * 1024 * 1024 } fn default_cache_ttl() -> u64 { @@ -306,7 +313,7 @@ mod tests { assert_eq!(kv.param, ""); assert_eq!(kv.name, "store"); assert_eq!(kv.prefix, "pre"); - assert_eq!(kv.cache_size, 25000); + assert_eq!(kv.cache_size, 10 * 1024 * 1024); assert_eq!(kv.cache_ttl, 60); } @@ -316,14 +323,14 @@ mod tests { "param": "url2", "name": "store2", "prefix": "pre2", - "cache_size": 5000, + "cache_size": 5000000, "cache_ttl": 120 }"#; let kv: KvStoreOption = serde_json::from_str(json).unwrap(); assert_eq!(kv.param, "url2"); assert_eq!(kv.name, "store2"); assert_eq!(kv.prefix, "pre2"); - assert_eq!(kv.cache_size, 5000); + assert_eq!(kv.cache_size, 5000000); assert_eq!(kv.cache_ttl, 120); } diff --git a/crates/runtime/src/instances.rs b/crates/runtime/src/instances.rs index 9dd1e89..ddbd0e2 100644 --- a/crates/runtime/src/instances.rs +++ b/crates/runtime/src/instances.rs @@ -14,78 +14,79 @@ //! through `StoreBuilder::build` — ProxyWasm, `http-handler`, `wasi:http` — is counted //! against the same pool it actually draws from. There is deliberately no `executor` label: //! the pools are per-`Engine` and shared, so only the total is meaningful for sizing. +//! +//! The counters are plain atomics; embedders export them (e.g. as the Prometheus gauges +//! `fastedge_wasm_instances_live` / `fastedge_wasm_instances_peak`) by calling +//! [`flush_peak_and_live`] once per scrape. That returns both values from a single `live` +//! sample, which is what keeps the exported peak from ever reading below the exported +//! live count; reading [`live`] and [`flush_peak`] separately does not. + +use std::sync::atomic::{AtomicI64, Ordering}; + +/// Currently live instances. +static LIVE: AtomicI64 = AtomicI64::new(0); + +/// Running max since the previous [`flush_peak`]. Kept separately so the peak can be +/// updated with an atomic `fetch_max` and drained with `swap(0)` on scrape. A plain +/// scrape-time sample of [`LIVE`] would miss the sub-second concurrency spikes that a +/// stalled backend produces — exactly the peaks the pool has to be sized for. +static PEAK: AtomicI64 = AtomicI64::new(0); + +fn acquire() { + // `fetch_add` returns the previous value, so `+ 1` is the level that this + // acquisition genuinely produced — a valid sample for the peak. + let live = LIVE.fetch_add(1, Ordering::Relaxed) + 1; + PEAK.fetch_max(live, Ordering::Relaxed); +} -#[cfg(feature = "metrics")] -mod imp { - use lazy_static::lazy_static; - use prometheus::{IntGauge, register_int_gauge}; - use std::sync::atomic::{AtomicI64, Ordering}; - - lazy_static! { - static ref WASM_INSTANCES_LIVE: IntGauge = register_int_gauge!( - "fastedge_wasm_instances_live", - "WASM instances currently alive, each holding one slot in every wasmtime pooling-allocator pool" - ) - .unwrap(); - - /// Peak since the previous scrape. A plain gauge is sampled at the scrape - /// interval and will miss the sub-second concurrency spikes that a stalled backend - /// produces — exactly the peaks the pool has to be sized for. This one cannot. - static ref WASM_INSTANCES_PEAK: IntGauge = register_int_gauge!( - "fastedge_wasm_instances_peak", - "Highest number of concurrently live WASM instances observed since the \ - previous scrape (resets on scrape)" - ) - .unwrap(); - } - - /// Running max since the previous scrape, kept separately so the peak can be updated - /// with an atomic `fetch_max` — `IntGauge` only offers `set`, which cannot express - /// "raise to" — and drained with `swap(0)` by [`flush_peak`] on scrape. - static PEAK: AtomicI64 = AtomicI64::new(0); - - pub(super) fn acquire() { - WASM_INSTANCES_LIVE.inc(); - // Reading back after `inc` may observe another thread's concurrent increment. That - // is still a level that genuinely occurred, so it is a valid sample for the peak. - let live = WASM_INSTANCES_LIVE.get(); - PEAK.fetch_max(live, Ordering::Relaxed); - } - - pub(super) fn release() { - WASM_INSTANCES_LIVE.dec(); - } +fn release() { + LIVE.fetch_sub(1, Ordering::Relaxed); +} - /// Currently live instances. Test/diagnostic accessor. - pub fn live() -> i64 { - WASM_INSTANCES_LIVE.get() - } +/// Currently live instances. +pub fn live() -> i64 { + LIVE.load(Ordering::Relaxed) +} - /// Peak since the previous [`flush_peak`]. Test/diagnostic accessor. - pub fn peak() -> i64 { - PEAK.load(Ordering::Relaxed) - } +/// Peak since the previous [`flush_peak`]. Test/diagnostic accessor. +pub fn peak() -> i64 { + PEAK.load(Ordering::Relaxed) +} - /// Export and reset the peak gauge; call on each Prometheus scrape. Never reports - /// less than the currently live count, so a long-running steady load can't read as 0. - pub fn flush_peak() { - let peak = PEAK - .swap(0, Ordering::Relaxed) - .max(WASM_INSTANCES_LIVE.get()); - WASM_INSTANCES_PEAK.set(peak); - } +/// Drain and return the peak since the previous call; invoke on each metrics scrape. +/// Never reports less than the currently live count, so a long-running steady load +/// can't read as 0. +/// +/// Prefer [`flush_peak_and_live`] when exporting both gauges: reading `live` separately +/// can observe a *later*, higher level than the one folded in here and so publish a +/// scrape where peak < live. +pub fn flush_peak() -> i64 { + let (peak, _live) = flush_peak_and_live(); + peak } -#[cfg(not(feature = "metrics"))] -mod imp { - pub(super) fn acquire() {} - pub(super) fn release() {} - pub fn flush_peak() {} +/// Drain the peak and sample the live count for a single scrape, returning +/// `(peak, live)`. +/// +/// `live` is sampled *before* the peak is drained and is the same value folded into the +/// peak, so the returned pair always satisfies `peak >= live`. Sampling the two +/// independently does not: an instance acquired between the two reads raises `live` +/// above a peak that was already computed, publishing a scrape that contradicts the +/// documented "never less than currently live" semantics. +pub fn flush_peak_and_live() -> (i64, i64) { + // Exactly one `live()` read: taking the sample here and threading it through is what + // makes `peak >= live` structural rather than timing-dependent. + drain_peak_against(live()) } -pub use imp::flush_peak; -#[cfg(feature = "metrics")] -pub use imp::{live, peak}; +/// Drain `PEAK` and fold `live_sample` into it, returning `(peak, live_sample)`. +/// +/// Both halves of the returned pair derive from the single caller-supplied sample, so +/// `peak >= live` holds for any input — including a fully drained `PEAK`. +fn drain_peak_against(live_sample: i64) -> (i64, i64) { + let peak = PEAK.swap(0, Ordering::Relaxed).max(live_sample); + (peak, live_sample) +} /// RAII counter for one live WASM instance. /// @@ -97,7 +98,7 @@ pub struct LiveInstanceGuard; impl LiveInstanceGuard { pub fn new() -> Self { - imp::acquire(); + acquire(); LiveInstanceGuard } } @@ -110,18 +111,29 @@ impl Default for LiveInstanceGuard { impl Drop for LiveInstanceGuard { fn drop(&mut self) { - imp::release(); + release(); } } -#[cfg(all(test, feature = "metrics"))] +#[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + /// `LIVE` / `PEAK` are process-global, so a test that drains the peak must not run + /// alongside one asserting it is monotonic. Serialise every test that touches them. + fn serial() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } - /// Guards from concurrent tests share the process-global gauge, so assertions are on + /// Guards from concurrent tests share the process-global counter, so assertions are on /// deltas rather than absolute values. #[test] fn guard_tracks_live_count_and_raises_peak() { + let _serial = serial(); let before = live(); let first = LiveInstanceGuard::new(); @@ -139,4 +151,45 @@ mod tests { // The high-water mark is monotonic: dropping instances must not lower it. assert_eq!(peak(), peak_at_top); } + + /// The core invariant, independent of timing: whatever live level a scrape samples, + /// the peak reported alongside it is never lower — even when `PEAK` was just drained + /// and contributes nothing. This is the case the old two-read scrape got wrong: it + /// computed the peak from one `live()` read and published a second, later one. + #[test] + fn drained_peak_still_reports_at_least_the_live_sample() { + let _serial = serial(); + for live_sample in [0, 1, 7, 1_000] { + PEAK.store(0, Ordering::Relaxed); + let (peak, live) = drain_peak_against(live_sample); + assert_eq!(live, live_sample); + assert!(peak >= live, "peak {peak} < live {live}"); + } + } + + /// A recorded spike still wins when it exceeds the live level at scrape time. + #[test] + fn recorded_spike_outranks_a_lower_live_sample() { + let _serial = serial(); + PEAK.store(50, Ordering::Relaxed); + let (peak, live) = drain_peak_against(3); + assert_eq!((peak, live), (50, 3)); + // Draining is destructive: the next scrape falls back to the live sample. + let (peak, _) = drain_peak_against(3); + assert_eq!(peak, 3); + } + + /// End-to-end through the real globals: acquiring after a drain must not produce a + /// scrape where peak < live. + #[test] + fn flush_peak_and_live_holds_invariant_after_a_drain() { + let _serial = serial(); + let _first = LiveInstanceGuard::new(); + // Drain, so `PEAK` is 0 and only the live sample can carry the invariant. + let _ = flush_peak_and_live(); + + let _second = LiveInstanceGuard::new(); + let (peak, live) = flush_peak_and_live(); + assert!(peak >= live, "peak {peak} < live {live}"); + } } diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index d195e71..0abd979 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,14 +1,21 @@ use crate::app::KvStoreOption; use crate::store::HasStats; +use http_backend::access_log::ExtRequestLogHandle; use http_backend::stats::ExtStatsTimer; -use std::sync::Arc; +use std::net::Ipv4Addr; +use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Instant; +use std::sync::{Arc, OnceLock}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; use std::{fmt::Debug, ops::Deref}; use utils::{Dictionary, Utils}; use wasmtime_wasi::ResourceTable; use wasmtime_wasi::WasiCtxView; -use wasmtime_wasi_http::{HttpResult, WasiHttpCtx, WasiHttpView}; +use wasmtime_wasi_http::{ + DEFAULT_FORBIDDEN_HEADERS, RequestOptions, WasiBody, WasiHttpCtx, WasiHttpCtxView, + WasiHttpHooks, WasiHttpView, default_send_request, +}; use wasmtime_wasi_io::IoView; use crate::store::StoreBuilder; @@ -21,6 +28,7 @@ use wasmtime::{ }; use wit_component::ComponentEncoder; +pub mod access_log; pub mod app; pub mod instances; mod limiter; @@ -29,48 +37,56 @@ mod registry; pub mod service; pub mod store; pub mod stub; +pub mod trace; pub mod util; +pub use registry::CachedGraphRegistry; +pub use trace::new_traceparent; + +use crate::access_log::{AccessLogSender, NoopAccessLogSender}; use crate::app::SecretOption; use crate::logger::Logger; use crate::util::stats::StatsVisitor; use anyhow::{anyhow, bail}; pub use app::{App, SecretValue, SecretValues}; +use bytes::Bytes; use http::request::Parts; -use http::{HeaderName, Request, header}; +use http::{HeaderName, Request, Response, header}; +use http_body::{Body, Frame, SizeHint}; +use http_body_util::BodyExt; use secret::SecretStore; use smol_str::SmolStr; use std::borrow::Cow; +use std::future::Future; use wasmtime_environ::wasmparser::{Encoding, Parser, Payload}; -use wasmtime_wasi_http::body::HyperOutgoingBody; -use wasmtime_wasi_http::{ - bindings::http::types::ErrorCode, - types::{HostFutureIncomingResponse, OutgoingRequestConfig, default_send_request_handler}, -}; use wasmtime_wasi_nn::wit::WasiNnCtx; pub const DEFAULT_EPOCH_TICK_INTERVAL: u64 = 10; const PREVIEW1_ADAPTER: &[u8] = include_bytes!("adapters/wasi_snapshot_preview1.reactor.wasm"); +/// Outcome of one app call. +/// +/// Reported as the `outcome` label of `fastedge_calls_total` (see the server's +/// metrics sink) and, **as its discriminant**, as `fail_reason Int32` in the +/// ClickHouse stats table. Variants are therefore append-only: never reorder +/// or remove one. #[allow(non_camel_case_types)] #[derive(PartialEq, Copy, Clone, Debug)] pub enum AppResult { SUCCESS, - #[cfg(feature = "metrics")] UNKNOWN, TIMEOUT, OOM, OTHER, /// Request shed by admission control (server overloaded). - #[cfg(feature = "metrics")] OVERLOADED, /// Request rejected because the app is rate limited. - #[cfg(feature = "metrics")] RATE_LIMITED, /// Request rejected because the app is disabled or in draft. - #[cfg(feature = "metrics")] DISABLED, + /// Request rejected because the app is suspended. + SUSPENDED, } pub type InstancePre = wasmtime::component::InstancePre>; @@ -89,16 +105,53 @@ pub enum WasiVersion { /// Wrapper for the Preview 1 and Preview 2 versions of `WasiCtx`. pub enum Wasi { /// Preview 1 `WasiCtx` - Preview1(wasmtime_wasi::preview1::WasiP1Ctx), + Preview1(wasmtime_wasi::p1::WasiP1Ctx), /// Preview 2 `WasiCtx` Preview2(wasmtime_wasi::WasiCtx), } +/// The embedder-side `wasi:http` overrides, bundled with the guest state `T`. +/// +/// wasmtime 48 split the old `WasiHttpView` in two: the view now only hands out +/// the HTTP context, the resource table and a `&mut dyn WasiHttpHooks`, while +/// the overrides themselves (`send_request`, `is_forbidden_header`) moved onto +/// [`WasiHttpHooks`]. Because the hooks are borrowed out of the store data as a +/// single trait object, everything an override needs has to live in one struct. +/// That is this struct: it owns the guest state `T` (reached from the rest of +/// the runtime through `AsRef`/`AsMut` on [`Data`]) alongside the epoch +/// bookkeeping that `send_request` updates. +pub struct HttpHooks { + inner: T, + /// Milliseconds of host I/O that should not count against the epoch + /// deadline. Shared with the `epoch_deadline_callback` installed on the + /// Store, which drains this counter to extend the deadline. + epoch_pause_ms: Arc, + /// Whether elapsed time of external HTTP should refund epoch ticks. + pause_epoch_timeout_for_external_http: bool, +} + +impl HttpHooks { + pub fn new( + inner: T, + epoch_pause_ms: Arc, + pause_epoch_timeout_for_external_http: bool, + ) -> Self { + Self { + inner, + epoch_pause_ms, + pause_epoch_timeout_for_external_http, + } + } +} + /// Host state data associated with individual [Store]s and [Instance]s. pub struct Data { - inner: T, + hooks: HttpHooks, wasi: Wasi, - pub wasi_nn: WasiNnCtx, + /// `None` for a store whose builder was never `preload_wasi_nn`ed — the + /// proxy-wasm engine does not link wasi-nn, so its stores skip the context + /// (and its allocations) entirely. + pub wasi_nn: Option, // memory usage limiter store_limits: ProxyLimiter, pub timeout: u64, @@ -110,13 +163,6 @@ pub struct Data { pub dictionary: Dictionary, pub utils: Utils, pub cache: cache::CacheImpl, - /// Milliseconds of host I/O that should not count against the epoch - /// deadline. The `epoch_deadline_callback` installed on the Store reads - /// and clears this counter, converting milliseconds into extra ticks to - /// extend the deadline. - pub epoch_pause_ms: Arc, - /// Whether elapsed time of external HTTP should refund epoch ticks. - pub pause_epoch_timeout_for_external_http: bool, /// Counts this instance in `fastedge_wasm_instances_live` for as long as the store — /// and therefore its pooling-allocator slots — is alive. Held only for its `Drop`. _live_instance: crate::instances::LiveInstanceGuard, @@ -124,17 +170,24 @@ pub struct Data { pub trait BackendRequest { fn backend_request(&mut self, head: Parts) -> anyhow::Result; + + /// Snapshot the outbound-request access-log handle, if configured. Default + /// `None` (no outbound-request logging). Implemented by embedders that carry + /// a sink; used by the WASI-HTTP send path. + fn ext_request_log_handle(&self) -> Option { + None + } } impl AsRef for Data { fn as_ref(&self) -> &T { - &self.inner + &self.hooks.inner } } impl AsMut for Data { fn as_mut(&mut self) -> &mut T { - &mut self.inner + &mut self.hooks.inner } } @@ -144,49 +197,171 @@ impl IoView for Data { } } -impl WasiHttpView for Data { - fn ctx(&mut self) -> &mut WasiHttpCtx { - &mut self.http +/// Boxed future used by [`WasiHttpHooks::send_request`] to report an error +/// raised while the request or response body was being processed. +type HttpIoFuture = Box> + Send>; + +/// Deposits host I/O wait time into the shared epoch-pause counter, carrying +/// sub-millisecond remainders across deposits so that bodies streamed in many +/// small frames do not have their wait time truncated away frame by frame. +struct EpochRefund { + epoch_pause_ms: Arc, + carry: Duration, +} + +impl EpochRefund { + fn new(epoch_pause_ms: Arc) -> Self { + Self { + epoch_pause_ms, + carry: Duration::ZERO, + } } - fn send_request( - &mut self, - request: Request, - config: OutgoingRequestConfig, - ) -> HttpResult - where - Self: Sized, - { - let (head, body) = request.into_parts(); - let head = self.inner.backend_request(head).map_err(|e| { - tracing::warn!(cause=?e, "backend request"); - ErrorCode::InternalError(Some(e.to_string())) - })?; - let use_tls = matches!(head.uri.scheme_str(), Some("https")); - let request = Request::from_parts(head, body); - // start external request stats timer - let stats = self.inner.get_stats(); - let epoch_pause_ms = self.epoch_pause_ms.clone(); - let epoch_exclude_http_wait = self.pause_epoch_timeout_for_external_http; + fn deposit(&mut self, elapsed: Duration) { + let total = self.carry + elapsed; + let ms = total.as_millis() as u64; + self.carry = total - Duration::from_millis(ms); + if ms > 0 { + self.epoch_pause_ms.fetch_add(ms, Ordering::Relaxed); + } + } +} - let handle = wasmtime_wasi::runtime::spawn(async move { - let _stats_timer = ExtStatsTimer::new(stats); // keep timer alive until request is done - let started = Instant::now(); - let resp = - default_send_request_handler(request, OutgoingRequestConfig { use_tls, ..config }) - .await; - if epoch_exclude_http_wait { - epoch_pause_ms.fetch_add(started.elapsed().as_millis() as u64, Ordering::Relaxed); +/// Response-body wrapper that refunds epoch ticks for time spent waiting on +/// the network. `default_send_request` resolves as soon as the response +/// *headers* are in; the body then streams lazily through the returned +/// `Response` and is only polled when the guest reads it. Any time a frame +/// poll stays `Pending` the guest is blocked on host I/O, so that time is +/// deposited into `epoch_pause_ms`, mirroring the send-phase refund in +/// [`WasiHttpHooks::send_request`]. +struct ResponseBodyEpochRefund { + inner: WasiBody, + refund: EpochRefund, + /// When the in-progress frame poll first returned `Pending`. + pending_since: Option, +} + +impl Body for ResponseBodyEpochRefund { + type Data = Bytes; + type Error = wasmtime_wasi_http::Error; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = self.get_mut(); + let result = Pin::new(&mut this.inner).poll_frame(cx); + match &result { + Poll::Pending => { + this.pending_since.get_or_insert_with(Instant::now); + } + Poll::Ready(_) => { + if let Some(started) = this.pending_since.take() { + this.refund.deposit(started.elapsed()); + } } - Ok(resp) - }); - Ok(HostFutureIncomingResponse::pending(handle)) + } + result } - fn table(&mut self) -> &mut ResourceTable { - &mut self.table + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() } +} +impl Drop for ResponseBodyEpochRefund { + fn drop(&mut self) { + // Body dropped mid-wait (e.g. the request ended early): flush the tail. + if let Some(started) = self.pending_since.take() { + self.refund.deposit(started.elapsed()); + } + } +} + +/// Request-body wrapper covering the tail of the request-body lifetime. +/// +/// Until the response headers arrive, [`WasiHttpHooks::send_request`] refunds +/// the whole wall clock of `default_send_request`, which already includes +/// request-body streaming, so no extra accounting is needed there (and any +/// would double count). But hyper may still be sending the request body +/// *after* the headers arrived — driven by the background `io` future — while +/// the guest blocks in a body write waiting for backpressure to clear. That +/// wait shows up as the gap between this body yielding a frame and hyper +/// polling for the next one; a `Pending` poll, by contrast, means the guest +/// itself has not produced data yet and must stay on the clock. Gaps are +/// refunded only for their portion past `headers_at`. +struct RequestBodyEpochRefund { + inner: WasiBody, + refund: EpochRefund, + /// Set once the response headers are in (send-phase refund window closed). + headers_at: Arc>, + /// When the previous poll yielded a frame. + ready_since: Option, +} + +impl RequestBodyEpochRefund { + /// Refund the elapsed part of the current inter-poll gap that falls + /// outside the send-phase refund window. + fn settle_gap(&mut self) { + let Some(ready) = self.ready_since.take() else { + return; + }; + let Some(headers) = self.headers_at.get() else { + return; + }; + self.refund.deposit(ready.max(*headers).elapsed()); + } +} + +impl Body for RequestBodyEpochRefund { + type Data = Bytes; + type Error = wasmtime_wasi_http::Error; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = self.get_mut(); + this.settle_gap(); + let result = Pin::new(&mut this.inner).poll_frame(cx); + if let Poll::Ready(Some(Ok(_))) = &result { + this.ready_since = Some(Instant::now()); + } + result + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() + } +} + +impl Drop for RequestBodyEpochRefund { + fn drop(&mut self) { + // hyper drops the body when it is done sending it (or the connection + // failed); settle the wait between the last yielded frame and now. + self.settle_gap(); + } +} + +impl WasiHttpView for Data { + fn http(&mut self) -> WasiHttpCtxView<'_> { + WasiHttpCtxView { + ctx: &mut self.http, + table: &mut self.table, + hooks: &mut self.hooks, + } + } +} + +impl WasiHttpHooks for HttpHooks { fn is_forbidden_header(&mut self, name: &HeaderName) -> bool { // We want to allow the host header to be set. if name.eq(&header::HOST) { @@ -199,12 +374,104 @@ impl WasiHttpView for Data { return true; } // Fall back to wasmtime's default forbidden-header policy. - wasmtime_wasi_http::types::DEFAULT_FORBIDDEN_HEADERS.contains(name) + DEFAULT_FORBIDDEN_HEADERS.contains(name) + } + + fn send_request( + &mut self, + request: Request, + options: Option, + fut: HttpIoFuture, + ) -> Box< + dyn Future, HttpIoFuture)>> + Send, + > { + // The request-side error channel is unused: a request rejected by + // `backend_request` fails the whole call below instead. + let _ = fut; + + // Capture the outbound method/target and access-log handle before the + // request is consumed. The URI here is the original external target, + // before `backend_request` rewrites it to the internal backend + // authority. + let log_method = SmolStr::new(request.method().as_str()); + let log_uri = SmolStr::new(request.uri().to_string()); + let log_handle = self.inner.ext_request_log_handle(); + + let (head, body) = request.into_parts(); + let head = match self.inner.backend_request(head) { + Ok(head) => head, + Err(e) => { + tracing::warn!(cause=?e, "backend request"); + let cause = e.to_string(); + return Box::new(async move { + Err(wasmtime_wasi_http::Error::InternalError(Some(cause))) + }); + } + }; + // `default_send_request` derives TLS usage from the rewritten URI's + // scheme, so the backend rewrite above decides it. + let request = Request::from_parts(head, body); + // start external request stats timer + let stats = self.inner.get_stats(); + let epoch_pause_ms = self.epoch_pause_ms.clone(); + let epoch_exclude_http_wait = self.pause_epoch_timeout_for_external_http; + + Box::new(async move { + let _stats_timer = ExtStatsTimer::new(stats); // keep timer alive until response head is in + // Set once the response headers are in; the request-body wrapper + // only refunds waits that fall outside the send-phase window below. + let headers_at = Arc::new(OnceLock::new()); + let request = if epoch_exclude_http_wait { + request.map(|body| { + RequestBodyEpochRefund { + inner: body, + refund: EpochRefund::new(epoch_pause_ms.clone()), + headers_at: headers_at.clone(), + ready_since: None, + } + .boxed_unsync() + }) + } else { + request + }; + let started = Instant::now(); + let sent = default_send_request(request, options).await; + let elapsed = started.elapsed(); + if epoch_exclude_http_wait { + epoch_pause_ms.fetch_add(elapsed.as_millis() as u64, Ordering::Relaxed); + let _ = headers_at.set(Instant::now()); + } + // One access-log record per outbound request (status 0 = no response + // received, e.g. connection refused / timeout). + if let Some(handle) = log_handle { + let status = match &sent { + Ok((incoming, _)) => incoming.status().as_u16(), + Err(_) => 0, + }; + handle.log(&log_method, &log_uri, status, elapsed); + } + let (response, io) = sent?; + // The send-phase refund above stops at the response headers; the + // body wrappers keep refunding network wait past that point. + let response = if epoch_exclude_http_wait { + response.map(|body| { + ResponseBodyEpochRefund { + inner: body.boxed_unsync(), + refund: EpochRefund::new(epoch_pause_ms.clone()), + pending_since: None, + } + .boxed_unsync() + }) + } else { + response.map(BodyExt::boxed_unsync) + }; + Ok((response, Box::new(io) as HttpIoFuture)) + }) } } impl Data { - pub fn preview1_wasi_ctx_mut(&mut self) -> &mut wasmtime_wasi::preview1::WasiP1Ctx { + pub fn preview1_wasi_ctx_mut(&mut self) -> &mut wasmtime_wasi::p1::WasiP1Ctx { match &mut self.wasi { Wasi::Preview1(ctx) => ctx, Wasi::Preview2(_) => unreachable!("using WASI Preview 2 functions with Preview 1 ctx"), @@ -265,10 +532,8 @@ impl Default for WasmConfig { // Debug build: keep full, symbolized guest backtraces. We are optimizing // execution CPU, not trap-path cost, and this is the standalone debug // runner — detailed backtraces are the whole point. - inner.wasm_backtrace(true); inner.wasm_backtrace_details(WasmBacktraceDetails::Enable); - inner.async_support(true); inner.consume_fuel(false); // this is custom Gcore setting inner.profiler(ProfilingStrategy::None); inner.epoch_interruption(true); // required by store.rs timeout mechanism @@ -322,6 +587,9 @@ pub struct WasmEngine { inner: Engine, component_linker: ComponentLinker, module_linker: ModuleLinker, + /// One wasi-nn graph cache for all stores of this engine, so a model is + /// loaded once per process rather than once per executor (see `registry`). + graph_registry: crate::registry::CachedGraphRegistry, } // Manual impl: `derive(Clone)` would incorrectly require `T: Clone`, but the @@ -334,6 +602,7 @@ impl Clone for WasmEngine { inner: self.inner.clone(), component_linker: self.component_linker.clone(), module_linker: self.module_linker.clone(), + graph_registry: self.graph_registry.clone(), } } } @@ -345,6 +614,7 @@ pub struct WasmEngineBuilder { engine: Engine, component_linker: ComponentLinker, module_linker: ModuleLinker, + graph_registry: crate::registry::CachedGraphRegistry, } impl WasmEngine { @@ -354,7 +624,7 @@ impl WasmEngine { } pub fn store_builder(&self, version: WasiVersion) -> StoreBuilder { - StoreBuilder::new(self.inner.clone(), version) + StoreBuilder::new(self.inner.clone(), version, self.graph_registry.clone()) } /// Creates a new [`InstancePre`] for the given [`Component`]. @@ -380,9 +650,18 @@ impl WasmEngineBuilder { engine: Engine::clone(engine), component_linker, module_linker, + graph_registry: crate::registry::CachedGraphRegistry::new(), }) } + /// Shares `registry` with this engine instead of the private one it starts + /// with. Every engine of a process must get the same registry, or each one + /// loads its own copy of a model (see [`ContextT::graph_registry`]). + pub fn graph_registry(&mut self, registry: crate::registry::CachedGraphRegistry) -> &mut Self { + self.graph_registry = registry; + self + } + pub fn component_linker_ref(&mut self) -> &mut ComponentLinker { &mut self.component_linker } @@ -397,6 +676,7 @@ impl WasmEngineBuilder { inner: self.engine, component_linker: self.component_linker, module_linker: self.module_linker, + graph_registry: self.graph_registry, } } } @@ -425,6 +705,12 @@ pub trait ContextT { fn engine_ref(&self) -> &Engine; + /// The process-wide wasi-nn graph cache. Every engine built from this + /// context (one per service) shares it, so a model is loaded once per + /// process rather than once per service. Return a clone of one long-lived + /// instance, never a fresh registry per call. + fn graph_registry(&self) -> crate::registry::CachedGraphRegistry; + fn make_secret_store(&self, secrets: &Vec) -> anyhow::Result; fn make_key_value_store(&self, stores: &Vec) -> key_value_store::Builder; @@ -433,8 +719,28 @@ pub trait ContextT { &self, request_id: &SmolStr, app: &SmolStr, + caller_ip: Ipv4Addr, cfg: &App, ) -> Arc; + + /// Exact `User-Agent` value whose requests are not persisted to the stats + /// store (synthetic probes such as a security scanner). The comparison is + /// byte-exact and case-sensitive; `None` disables the filter. + fn stats_excluded_user_agent(&self) -> Option<&str> { + None + } + + /// Sink for the per-request system log (see [`access_log`]). + /// + /// Distinct from the guest application log produced by [`make_logger`]. + /// Defaults to a no-op until a concrete appender (e.g. UDP syslog) is wired + /// in, so existing embedders need not implement it. + /// + /// [`make_logger`]: ContextT::make_logger + fn access_log_sender(&self) -> &dyn AccessLogSender { + static NOOP: NoopAccessLogSender = NoopAccessLogSender; + &NOOP + } } pub trait ExecutorCache { diff --git a/crates/runtime/src/limiter.rs b/crates/runtime/src/limiter.rs index 355e7d1..c91d561 100644 --- a/crates/runtime/src/limiter.rs +++ b/crates/runtime/src/limiter.rs @@ -1,6 +1,5 @@ -use anyhow::{Error, Result}; use tracing::instrument; -use wasmtime::{ResourceLimiter, StoreLimits}; +use wasmtime::{Error, ResourceLimiter, Result, StoreLimits}; /// A proxy wrapper of `wasmtime::ResourceLimiter` trait impl used to track used memory #[derive(Clone, Debug)] diff --git a/crates/runtime/src/logger.rs b/crates/runtime/src/logger.rs index 9d1debd..b58a095 100644 --- a/crates/runtime/src/logger.rs +++ b/crates/runtime/src/logger.rs @@ -1,15 +1,11 @@ use async_trait::async_trait; use bytes::Bytes; use chrono::{DateTime, Utc}; -use std::any::Any; use std::collections::HashMap; -use std::io::IoSlice; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; use tokio::io::{AsyncWrite, AsyncWriteExt}; -use wasi_common::file::{FdFlags, FileType}; -use wasi_common::{Error, WasiFile}; use wasmtime_wasi::cli::{IsTerminal, StdoutStream}; use wasmtime_wasi_io::poll::Pollable; use wasmtime_wasi_io::streams::OutputStream; @@ -273,27 +269,3 @@ impl OutputStream for Console { Ok(usize::MAX) } } - -#[async_trait] -impl WasiFile for Console { - fn as_any(&self) -> &dyn Any { - self - } - - async fn get_filetype(&self) -> Result { - Ok(FileType::Pipe) - } - - async fn get_fdflags(&self) -> Result { - Ok(FdFlags::APPEND) - } - - async fn write_vectored<'a>(&self, bufs: &[IoSlice<'a>]) -> Result { - let n = bufs.iter().fold(0, |n, buf| { - print!("{}", std::str::from_utf8(&buf).unwrap()); - n + buf.len() - }); - - Ok(n as u64) - } -} diff --git a/crates/runtime/src/registry.rs b/crates/runtime/src/registry.rs index cb502fa..6ebf770 100644 --- a/crates/runtime/src/registry.rs +++ b/crates/runtime/src/registry.rs @@ -1,22 +1,168 @@ +//! wasi-nn graph (ML model) loading and caching. +//! +//! Graphs are big — a production model weighs ~2.3 GiB resident — and slow to +//! load (seconds: the OpenVINO plugin reads and compiles the network). So they +//! are loaded once per process in [`CachedGraphRegistry`], owned by the +//! embedder's context (`ContextT::graph_registry`) and shared by every +//! `WasmEngine` built from it and every `StoreBuilder` of those, and an executor resolves its app's graphs +//! when it is created (`StoreBuilder::preload_wasi_nn`, on the blocking pool), +//! not on the request path. Each `Store` then only clones `Graph` handles +//! (`Arc`s) into its `WasiNnCtx`. +//! +//! Which graphs an app gets is driven by its env: `FASTEDGE_NN_BACKENDS` lists +//! the backends (`openvino`, `candle`), and every key starting with +//! `FASTEDGE_NN_` names a directory the backend can load a graph from. +//! The guest refers to the graph by that directory's last path component. + use std::collections::HashMap; -use std::ops::Deref; -use std::path::Path; -use std::time::Duration; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; +use moka::ops::compute::Op; use moka::sync::Cache; use wasmtime_wasi_nn::backend::candle::CandleBackend; +use wasmtime_wasi_nn::wit::WasiNnCtx; use wasmtime_wasi_nn::wit::types::GraphEncoding; use wasmtime_wasi_nn::{ GraphRegistry, Registry, - backend::{BackendFromDir, openvino::OpenvinoBackend}, + backend::openvino::OpenvinoBackend, wit::types::ExecutionTarget, {Backend, Graph}, }; +/// How long a graph stays loaded with no `Store` using it. +/// +/// Long on purpose: dropping a graph only pays off for a model nobody asks for +/// any more (a replaced one), while expiring one that is merely quiet costs a +/// multi-second reload and, briefly, a second resident copy next to the handles +/// idle executors still hold. Hours, not minutes. +pub const GRAPH_TIME_TO_IDLE: Duration = Duration::from_secs(4 * 60 * 60); + +/// `(backend kind, graph directory)` pairs an app asks to have loaded. +pub type PreloadSpec = Vec<(String, String)>; + +/// What the process-wide cache is keyed by: the backend and the graph's +/// canonical directory. Two apps pointing at `/tenant-a/model` and +/// `/tenant-b/model` are two graphs; the name the guest sees (`model`) is a +/// per-store matter ([`CachedGraphRegistry::registry_for`]), never the key. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct GraphKey { + kind: String, + path: PathBuf, +} + +impl GraphKey { + fn new(kind: &str, path: &str) -> Result { + // Canonical, so `/models/current -> /models/v2` and `/models/v2` are one + // graph, and a re-pointed symlink is a new one. + let path = std::fs::canonicalize(path) + .map_err(|_| anyhow!("preload directory is not a valid directory: {}", path))?; + Ok(Self { + kind: kind.to_ascii_lowercase(), + path, + }) + } +} + +/// Identity of a graph directory's *contents* at preload time: name, size and +/// mtime of every regular file in it. A model replaced in place (rsync, cp) +/// changes it, so the next executor build loads the new files instead of +/// serving the cached graph until the process restarts. One `stat` per file; +/// no hashing of multi-GiB weights. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Revision(u64); + +/// What one scan of a graph directory tells: its [`Revision`] and the bytes +/// on disk (for the load log; the resident size is larger, see `rss_bytes`). +#[derive(Clone, Copy, Debug)] +struct DirFiles { + revision: Revision, + bytes: u64, +} + +impl DirFiles { + fn scan(dir: &Path) -> Result { + let mut entries = std::fs::read_dir(dir) + .and_then(|it| it.collect::>>()) + .with_context(|| format!("read graph directory {}", dir.display()))?; + entries.sort_by_key(|e| e.file_name()); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let mut bytes = 0; + for entry in entries { + let meta = entry + .metadata() + .with_context(|| format!("stat {}", entry.path().display()))?; + if meta.is_file() { + entry.file_name().hash(&mut hasher); + meta.len().hash(&mut hasher); + meta.modified().ok().hash(&mut hasher); + bytes += meta.len(); + } + } + Ok(Self { + revision: Revision(hasher.finish()), + bytes, + }) + } +} + +/// This process's resident set, for the before/after of a model load. Linux +/// only (`/proc/self/statm`); `None` elsewhere or on any read error. +fn rss_bytes() -> Option { + #[cfg(target_os = "linux")] + { + let statm = std::fs::read_to_string("/proc/self/statm").ok()?; + let resident_pages: u64 = statm.split_whitespace().nth(1)?.parse().ok()?; + Some(resident_pages * 4096) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +/// A cache entry: the graph plus the revision of the files it was loaded from. +#[derive(Clone)] +struct CachedGraph { + revision: Revision, + graph: Graph, +} + +/// One graph as resolved for a store builder: the shared cache entry it came +/// from, the name the guest loads it by, and the handle. +#[derive(Clone)] +pub struct LoadedGraph { + pub key: GraphKey, + pub name: String, + pub graph: Graph, +} + +/// Process-wide cache of loaded graphs, keyed by [`GraphKey`]. +/// +/// Cheap to clone (the cache is reference-counted); one instance lives on the +/// embedder's context, is shared by every `WasmEngine` built from it, and is +/// handed to every `StoreBuilder` those engines create. +/// +/// Entries are strong: an executor rebuild (config change, debug toggle) finds +/// the graph here and never reloads it. A graph is dropped after +/// [`GRAPH_TIME_TO_IDLE`] without *traffic* — executors only read the cache at +/// preload, so every `Store` creation refreshes its graphs' idle clocks through +/// [`CachedGraphRegistry::mark_used`]. Without that, a model under constant +/// load would expire on schedule and the next executor rebuild would load a +/// second ~2.3 GiB copy next to the one live executors still hold. #[derive(Clone)] -pub struct CachedGraphRegistry(Cache); +pub struct CachedGraphRegistry(Cache); +impl Default for CachedGraphRegistry { + fn default() -> Self { + Self::new() + } +} + +/// The graphs one `Store` can see, by name. pub struct StoreRegistry(HashMap); pub fn backends() -> Vec { @@ -36,67 +182,403 @@ impl GraphRegistry for StoreRegistry { } } +/// Reads an app's graph preload requests from its env. +/// +/// `FASTEDGE_NN_BACKENDS=openvino,candle` selects the backends; for each, every +/// env key starting with `FASTEDGE_NN_` (backend upper-cased) holds a +/// graph directory. Order follows the backend list, then the env order. +pub fn preload_spec, V: AsRef>(env: &[(K, V)]) -> PreloadSpec { + let Some((_, backends)) = env + .iter() + .find(|(k, _)| k.as_ref() == "FASTEDGE_NN_BACKENDS") + else { + return Vec::new(); + }; + + backends + .as_ref() + .split(',') + .map(str::trim) + .filter(|b| !b.is_empty()) + .flat_map(|backend| { + let prefix = format!("FASTEDGE_NN_{}", backend.to_ascii_uppercase()); + env.iter().filter_map(move |(k, v)| { + k.as_ref() + .starts_with(&prefix) + .then(|| (backend.to_string(), v.as_ref().to_string())) + }) + }) + .collect() +} + +/// The name a graph is registered under: its directory's last path component. +fn graph_name(path: &str) -> Result { + Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .ok_or_else(|| anyhow!("no file name in graph path: {}", path)) +} + +/// Loads one graph from `path` with the backend `kind`. Slow and memory-hungry; +/// only ever called through the cache. +fn load_graph(kind: &str, path: &str) -> Result { + let dir = Path::new(path); + if !dir.is_dir() { + bail!("preload directory is not a valid directory: {}", path); + } + let encoding = kind.parse().unwrap_or(GraphEncoding::Autodetect); + let mut backends = backends(); + let backend = backends + .iter_mut() + .find(|b| b.encoding() == encoding) + .ok_or_else(|| anyhow!("unsupported backend: {}", kind))? + .as_dir_loadable() + .ok_or_else(|| anyhow!("{} does not support directory loading", kind))?; + Ok(backend.load_from_dir(dir, ExecutionTarget::Cpu)?) +} + impl CachedGraphRegistry { pub fn new() -> Self { + Self::with_time_to_idle(GRAPH_TIME_TO_IDLE) + } + + fn with_time_to_idle(time_to_idle: Duration) -> Self { let builder = Cache::builder() - // Max 10,000 entries + // Max 100 entries .max_capacity(100) - // Time to idle (TTI): 30 minutes - .time_to_idle(Duration::from_secs(30 * 60)); + .time_to_idle(time_to_idle) + // Models are few and each is gigabytes: say when one leaves the + // cache and why. Memory is only freed once executors built on it + // drop their handles. + .eviction_listener(|key: Arc, _: CachedGraph, cause| { + tracing::info!( + backend = %key.kind, + path = %key.path.display(), + ?cause, + "wasi-nn graph dropped from the process cache" + ); + }); Self(builder.build()) } - /// Load a graph from the files contained in the `path` directory. + /// Records that `graphs` are in use, so their entries do not expire while + /// requests keep coming. Called on every `Store` creation; a Moka read is + /// a hash lookup plus a buffered access note, nothing on the order of the + /// store itself. + pub fn mark_used(&self, graphs: &[LoadedGraph]) { + for g in graphs { + let _ = self.0.get(&g.key); + } + } + + /// Resolves every graph in `spec`, loading the ones not cached yet — or + /// cached from files that have since changed ([`Revision`]). /// - /// This expects the backend to know how to load graphs (i.e., ML model) - /// from a directory. The name used in the registry is the directory's last - /// suffix: if the backend can find the files it expects in `/my/model/foo`, - /// the registry will contain a new graph named `foo`. - fn load(&self, backend: &mut dyn BackendFromDir, path: impl AsRef) -> Result { - let path = path.as_ref(); - if !path.is_dir() { - bail!( - "preload directory is not a valid directory: {}", - path.display() - ); + /// The per-key compute is atomic: concurrent requests for the same graph + /// wait for a single load instead of each running one (a cold graph used + /// to be loaded once per concurrent miss, each copy costing the full model + /// footprint), and a stale entry is replaced in place, so the old graph is + /// freed as soon as the executors built on it are gone. The handles + /// returned keep a graph alive for the executor's lifetime; the cache + /// entry's own lifetime is a matter of traffic (see [`Self::mark_used`]). + pub fn load_graphs(&self, spec: &[(String, String)]) -> Result> { + let mut graphs: Vec = Vec::with_capacity(spec.len()); + for (kind, path) in spec { + let key = GraphKey::new(kind, path)?; + let name = graph_name(path)?; + // The guest addresses graphs by name, so within one app two + // directories with the same basename would shadow each other. + if let Some(dup) = graphs.iter().find(|g| g.name == name) { + bail!( + "wasi-nn graph name {:?} used by both {} and {}", + name, + dup.key.path.display(), + path + ); + } + let files = DirFiles::scan(&key.path)?; + let revision = files.revision; + let graph = self + .0 + .entry(key.clone()) + .and_try_compute_with(|current| match current { + Some(entry) if entry.value().revision == revision => { + tracing::debug!(backend = %kind, path = %path, name = %name, + "wasi-nn graph served from the process cache"); + Ok(Op::Nop) + } + current => { + let replacing = current.is_some(); + let rss_before = rss_bytes(); + let started = Instant::now(); + let graph = load_graph(kind, path)?; + tracing::info!( + backend = %kind, + path = %path, + name = %name, + file_bytes = files.bytes, + rss_delta_bytes = rss_before + .zip(rss_bytes()) + .map(|(before, after)| after.saturating_sub(before)), + elapsed_ms = started.elapsed().as_millis() as u64, + replacing, + "wasi-nn graph loaded into the process cache" + ); + Ok(Op::Put(CachedGraph { revision, graph })) + } + }) + .map_err(|error: anyhow::Error| { + anyhow!("wasi-nn backend load {}: {:#}", path, error) + })? + .into_entry() + .expect("Nop on a present entry or Put: the entry is there") + .into_value() + .graph; + graphs.push(LoadedGraph { key, name, graph }); } - let name = path - .file_name() - .map(|s| s.to_string_lossy()) - .ok_or(anyhow!("no file name in path"))?; - - let graph = backend.load_from_dir(path, ExecutionTarget::Cpu)?; - self.0.insert(name.into_owned(), graph.clone()); - Ok(graph) - } - - pub fn preload_graphs( - &self, - preload: Vec<(String, String)>, - ) -> Result<(impl IntoIterator, Registry)> { - let mut backends = backends(); - let mut registry = StoreRegistry(HashMap::new()); - for (kind, path) in preload { - let name = Path::new(&path) - .file_name() - .map(|s| s.to_string_lossy()) - .ok_or(anyhow!("no file name in path"))?; - let graph = if let Some(graph) = self.0.get(name.deref()) { - graph - } else { - let kind_ = kind.parse().unwrap_or(GraphEncoding::Autodetect); - let backend = backends - .iter_mut() - .find(|b| b.encoding() == kind_) - .ok_or(anyhow!("unsupported backend: {}", kind))? - .as_dir_loadable() - .ok_or(anyhow!("{} does not support directory loading", kind))?; - self.load(backend, &path) - .with_context(|| format!("wasi-nn backend load {}", path))? - }; - registry.0.insert(name.into_owned(), graph); + Ok(graphs) + } + + /// Builds a `Store`'s view of already-resolved graphs. Only `Arc` clones. + pub fn registry_for(graphs: &[LoadedGraph]) -> Registry { + Registry::from(StoreRegistry( + graphs + .iter() + .map(|g| (g.name.clone(), g.graph.clone())) + .collect(), + )) + } + + /// A `Store`'s wasi-nn context over already-resolved graphs: the backends + /// plus [`Self::registry_for`]. Only `Arc` clones and a few small allocs. + pub fn ctx_for(graphs: &[LoadedGraph]) -> WasiNnCtx { + WasiNnCtx::new(backends(), Self::registry_for(graphs)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn preload_spec_is_empty_without_backends() { + let e = env(&[("FASTEDGE_NN_OPENVINO_MODEL", "/models/a"), ("X", "y")]); + assert!(preload_spec(&e).is_empty()); + } + + #[test] + fn preload_spec_collects_dirs_per_backend_in_order() { + let e = env(&[ + ("FASTEDGE_NN_OPENVINO_MODEL_B", "/models/b"), + ("FASTEDGE_NN_BACKENDS", "openvino, candle"), + ("FASTEDGE_NN_CANDLE_MODEL", "/models/c"), + ("FASTEDGE_NN_OPENVINO_MODEL_A", "/models/a"), + ("UNRELATED", "/models/z"), + ]); + assert_eq!( + preload_spec(&e), + vec![ + ("openvino".to_string(), "/models/b".to_string()), + ("openvino".to_string(), "/models/a".to_string()), + ("candle".to_string(), "/models/c".to_string()), + ] + ); + } + + #[test] + fn preload_spec_matches_backend_prefix_case_insensitively() { + let e = env(&[ + ("FASTEDGE_NN_BACKENDS", "OpenVINO"), + ("FASTEDGE_NN_OPENVINO", "/models/a"), + ]); + assert_eq!( + preload_spec(&e), + vec![("OpenVINO".to_string(), "/models/a".to_string())] + ); + } + + #[test] + fn load_graphs_rejects_a_missing_directory_naming_it() { + let registry = CachedGraphRegistry::new(); + let spec = vec![("openvino".to_string(), "/definitely/not/here".to_string())]; + let error = registry + .load_graphs(&spec) + .err() + .expect("missing dir must fail") + .to_string(); + assert!(error.contains("/definitely/not/here"), "{error}"); + } + + #[test] + fn load_graphs_rejects_an_unsupported_backend() { + // A valid encoding no configured backend provides. (An unparseable kind + // falls back to `Autodetect`, which a backend does accept.) + let dir = std::env::temp_dir(); + let registry = CachedGraphRegistry::new(); + let spec = vec![("onnx".to_string(), dir.to_string_lossy().into_owned())]; + let error = registry + .load_graphs(&spec) + .err() + .expect("unknown backend must fail") + .to_string(); + assert!(error.contains("unsupported backend"), "{error}"); + } + + /// A graph that cannot run; enough to occupy a cache slot. + struct StubGraph; + + impl wasmtime_wasi_nn::backend::BackendGraph for StubGraph { + fn init_execution_context( + &self, + ) -> std::result::Result< + wasmtime_wasi_nn::ExecutionContext, + wasmtime_wasi_nn::backend::BackendError, + > { + Err(wasmtime_wasi_nn::backend::BackendError::NoInputs) } - Ok((backends, Registry::from(registry))) + } + + fn stub_graph() -> Graph { + Graph::from(Box::new(StubGraph) as Box) + } + + fn cached(graph: Graph) -> CachedGraph { + CachedGraph { + revision: Revision(0), + graph, + } + } + + /// Replacing a model's files in place must be seen by the next executor + /// build; touching nothing must not (or every rebuild would reload). + #[test] + fn revision_follows_the_files_contents_not_the_directory() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("model.bin"); + std::fs::write(&bin, b"v1").unwrap(); + std::fs::write(dir.path().join("model.xml"), b"").unwrap(); + let first = DirFiles::scan(dir.path()).unwrap().revision; + assert_eq!( + first, + DirFiles::scan(dir.path()).unwrap().revision, + "stable when untouched" + ); + + std::fs::write(&bin, b"v2-longer").unwrap(); + assert_ne!( + first, + DirFiles::scan(dir.path()).unwrap().revision, + "size change seen" + ); + + std::fs::write(dir.path().join("extra.bin"), b"x").unwrap(); + let with_extra = DirFiles::scan(dir.path()).unwrap().revision; + std::fs::remove_file(dir.path().join("extra.bin")).unwrap(); + assert_ne!( + with_extra, + DirFiles::scan(dir.path()).unwrap().revision, + "added/removed file seen" + ); + } + + /// The idle clock must follow store creations, not preloads: a model that + /// serves requests continuously stays cached past its idle timeout, and + /// one that stops being used is dropped. + #[test] + fn mark_used_keeps_a_busy_graph_cached_until_traffic_stops() { + let tti = Duration::from_millis(300); + let registry = CachedGraphRegistry::with_time_to_idle(tti); + let key = GraphKey { + kind: "stub".into(), + path: "/models/m".into(), + }; + registry.0.insert(key.clone(), cached(stub_graph())); + let held = vec![LoadedGraph { + key: key.clone(), + name: "m".into(), + graph: registry.0.get(&key).unwrap().graph, + }]; + + // Busy for 3x the idle timeout, touched every third of it. + for _ in 0..9 { + std::thread::sleep(tti / 3); + registry.mark_used(&held); + } + assert!(registry.0.get(&key).is_some(), "busy graph expired"); + + // Idle for 2x the timeout; still held by `held`, like an idle executor + // would, which must not keep the cache entry alive. + std::thread::sleep(tti * 2); + assert!(registry.0.get(&key).is_none(), "idle graph still cached"); + } + + /// Same basename, different directories: two cache entries, each app gets + /// its own graph under the name `model` — and a different backend for the + /// same directory is a third. + #[test] + fn graph_key_separates_same_basename_under_different_paths_and_backends() { + let root = tempfile::tempdir().unwrap(); + let a = root.path().join("tenant-a").join("model"); + let b = root.path().join("tenant-b").join("model"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let (a, b) = (a.to_str().unwrap(), b.to_str().unwrap()); + + let ka = GraphKey::new("openvino", a).unwrap(); + let kb = GraphKey::new("openvino", b).unwrap(); + let kc = GraphKey::new("candle", a).unwrap(); + assert_ne!(ka, kb); + assert_ne!(ka, kc); + assert_eq!( + ka, + GraphKey::new("OpenVINO", a).unwrap(), + "kind is case-insensitive" + ); + assert_eq!(graph_name(a).unwrap(), "model"); + assert_eq!(graph_name(b).unwrap(), "model"); + } + + /// Within one app two directories with the same basename cannot both be + /// addressed by the guest, so that is a config error, not a silent shadow. + #[test] + fn load_graphs_rejects_duplicate_names_within_an_app() { + let root = tempfile::tempdir().unwrap(); + let a = root.path().join("a").join("model"); + let b = root.path().join("b").join("model"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let registry = CachedGraphRegistry::with_time_to_idle(Duration::from_secs(60)); + // Cached at the directory's current revision, so `a` hits and the + // failure is the duplicate name, not a load. + registry.0.insert( + GraphKey::new("openvino", a.to_str().unwrap()).unwrap(), + CachedGraph { + revision: DirFiles::scan(&a).unwrap().revision, + graph: stub_graph(), + }, + ); + let error = registry + .load_graphs(&[ + ("openvino".into(), a.to_str().unwrap().into()), + ("openvino".into(), b.to_str().unwrap().into()), + ]) + .err() + .expect("duplicate name must fail") + .to_string(); + assert!(error.contains("\"model\""), "{error}"); + } + + #[test] + fn registry_for_exposes_graphs_by_name_and_empty_is_fine() { + let registry = CachedGraphRegistry::registry_for(&[]); + assert!(registry.get("anything").is_none()); } } diff --git a/crates/runtime/src/service.rs b/crates/runtime/src/service.rs index 9a3772f..5b0fb74 100644 --- a/crates/runtime/src/service.rs +++ b/crates/runtime/src/service.rs @@ -43,6 +43,7 @@ where pub fn build(self) -> anyhow::Result { let engine = { let mut builder = WasmEngine::builder(self.context.engine_ref())?; + builder.graph_registry(self.context.graph_registry()); S::configure_engine(&mut builder)?; builder.build() }; diff --git a/crates/runtime/src/store.rs b/crates/runtime/src/store.rs index 0435c92..0c87536 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -1,8 +1,8 @@ use crate::limiter::ProxyLimiter; use crate::logger::Logger; -use crate::registry::CachedGraphRegistry; +use crate::registry::{CachedGraphRegistry, LoadedGraph}; use crate::util::stats::StatsVisitor; -use crate::{DEFAULT_EPOCH_TICK_INTERVAL, Data, Wasi, WasiVersion}; +use crate::{DEFAULT_EPOCH_TICK_INTERVAL, Data, HttpHooks, Wasi, WasiVersion}; use anyhow::Result; use secret::SecretStore; use std::sync::Arc; @@ -126,7 +126,13 @@ pub struct StoreBuilder { logger: Option, version: WasiVersion, properties: HashMap, + /// Process-wide graph cache, shared with every other builder of the engine. registry: CachedGraphRegistry, + /// The app's wasi-nn graphs, resolved once by [`Self::preload_wasi_nn`] so + /// building a `Store` only clones handles. `None` until preloaded, and a + /// store built from a never-preloaded builder gets no wasi-nn context at + /// all: preloading is the opt-in (proxy-wasm does not link wasi-nn). + nn_graphs: Option>, secret_store: SecretStore, key_value_store: key_value_store::Builder, dictionary: Dictionary, @@ -138,7 +144,11 @@ pub struct StoreBuilder { impl StoreBuilder { // Called by Engine::store_builder. - pub(crate) fn new(engine: wasmtime::Engine, version: WasiVersion) -> Self { + pub(crate) fn new( + engine: wasmtime::Engine, + version: WasiVersion, + registry: CachedGraphRegistry, + ) -> Self { Self { engine, max_duration: 100, @@ -147,7 +157,8 @@ impl StoreBuilder { logger: None, version, properties: Default::default(), - registry: CachedGraphRegistry::new(), + registry, + nn_graphs: None, secret_store: Default::default(), key_value_store: key_value_store::Builder::default(), dictionary: Default::default(), @@ -268,35 +279,40 @@ impl StoreBuilder { } } - pub fn make_wasi_nn(&self) -> Result { - // initialize application specific graph - let backends: Vec<&str> = self - .env - .iter() - .find(|(k, _)| k == "FASTEDGE_NN_BACKENDS") - .map(|(_, v)| v.split(",").collect()) - .unwrap_or_default(); - - debug!("wasi-nn backends: {:?}", backends); - - let preload_graphs: Vec<(String, String)> = backends - .into_iter() - .flat_map(|b| { - let entry = self.env.iter().filter_map(|(k, v)| { - if k.starts_with(&format!("FASTEDGE_NN_{}", b.to_ascii_uppercase())) { - Some((b.to_string(), v.to_string())) - } else { - None - } - }); - entry - }) - .collect(); - debug!("preload graphs: {:?}", preload_graphs); + /// Resolves the app's wasi-nn graphs (from `FASTEDGE_NN_*` env, see + /// [`crate::registry`]) now, so that every `Store` built later gets them + /// for the price of an `Arc` clone. + /// + /// Call this where the executor is created — a cache miss loads a model, + /// which takes seconds and gigabytes, and that belongs on the blocking + /// pool, not on the request path. Loads are coalesced process-wide, so + /// concurrent executor creations for the same app share one load. + pub fn preload_wasi_nn(self) -> Result { + let spec = crate::registry::preload_spec(&self.env); + debug!("preload graphs: {:?}", spec); + let nn_graphs = self.registry.load_graphs(&spec)?; + Ok(Self { + nn_graphs: Some(nn_graphs), + ..self + }) + } - let (backends, registry) = self.registry.preload_graphs(preload_graphs)?; + /// Whether the app's env asks for wasi-nn graphs (`FASTEDGE_NN_*`). + /// Lets an executor factory whose engine lacks wasi-nn flag the misconfig + /// instead of silently ignoring it. + pub fn has_wasi_nn_env(&self) -> bool { + !crate::registry::preload_spec(&self.env).is_empty() + } - Ok(WasiNnCtx::new(backends, registry)) + /// The store's wasi-nn context, or `None` for a builder that was never + /// [`Self::preload_wasi_nn`]ed — that is the opt-in, and a store without it + /// pays nothing here (no backends, no registry, no allocations). + pub fn make_wasi_nn(&self) -> Option { + let graphs = self.nn_graphs.as_ref()?; + // Keep the process-wide entries alive while this app has traffic; + // nothing else reads them between executor rebuilds. + self.registry.mark_used(graphs); + Some(CachedGraphRegistry::ctx_for(graphs)) } /// Builds a [`Store`] from this builder with `Default` WasiCtxBuilder @@ -321,7 +337,7 @@ impl StoreBuilder { let table = ResourceTable::new(); wasi_ctx_builder.envs(&self.env); - let wasi_nn = self.make_wasi_nn()?; + let wasi_nn = self.make_wasi_nn(); let logger = if let Some(mut logger) = self.logger { logger.extend(self.properties); @@ -353,7 +369,7 @@ impl StoreBuilder { let mut inner = wasmtime::Store::new( &self.engine, Data { - inner, + hooks: HttpHooks::new(inner, epoch_pause_ms.clone(), self.epoch_exclude_http_wait), wasi, wasi_nn, store_limits: self.store_limits, @@ -374,8 +390,6 @@ impl StoreBuilder { dictionary: self.dictionary, utils, cache: cache_impl, - epoch_pause_ms: epoch_pause_ms.clone(), - pause_epoch_timeout_for_external_http: self.epoch_exclude_http_wait, _live_instance: crate::instances::LiveInstanceGuard::new(), }, ); @@ -387,7 +401,7 @@ impl StoreBuilder { inner.epoch_deadline_callback(move |_ctx| { let credit_ms = epoch_pause_ms.swap(0, Ordering::Relaxed); match epoch_credit_ticks(credit_ms) { - None => Err(anyhow::Error::new(wasmtime::Trap::Interrupt)), + None => Err(wasmtime::Error::new(wasmtime::Trap::Interrupt)), Some(ticks) => Ok(UpdateDeadline::Continue(ticks)), } }); @@ -498,7 +512,7 @@ mod tests { store.epoch_deadline_callback(move |_ctx| { let credit_ms = epoch_pause_ms.swap(0, Ordering::Relaxed); match epoch_credit_ticks(credit_ms) { - None => Err(anyhow::Error::new(Trap::Interrupt)), + None => Err(wasmtime::Error::new(Trap::Interrupt)), Some(ticks) => Ok(UpdateDeadline::Continue(ticks)), } }); @@ -602,10 +616,8 @@ mod tests { /// No-op stats sink; `StoreBuilder::build` only needs `HasStats` to wire the /// key-value store and utils host state. - #[cfg(feature = "metrics")] struct NoStats; - #[cfg(feature = "metrics")] mod no_stats_impls { use super::NoStats; use crate::util::stats::{CdnPhase, ReadStats, StatsVisitor}; @@ -616,6 +628,7 @@ mod tests { impl ReadStats for NoStats { fn count_kv_read(&self, _: i32) {} fn count_kv_byod_read(&self, _: i32) {} + fn count_kv_read_cached(&self) {} } impl UserDiagStats for NoStats { fn set_user_diag(&self, _: &str) {} @@ -638,7 +651,6 @@ mod tests { } } - #[cfg(feature = "metrics")] impl HasStats for NoStats { fn get_stats(&self) -> Arc { Arc::new(NoStats) @@ -648,7 +660,6 @@ mod tests { /// End-to-end wiring check: a store built the way every executor builds it must be /// counted in `fastedge_wasm_instances_live` for exactly as long as it is alive, since /// that is the window in which it holds pooling-allocator slots. - #[cfg(feature = "metrics")] #[test] fn store_lifetime_is_counted_as_a_live_instance() { use crate::instances; @@ -656,16 +667,20 @@ mod tests { let engine = make_engine(); let before = instances::live(); - let store = StoreBuilder::new(engine.clone(), WasiVersion::Preview1) - .build(NoStats) - .expect("build store"); + let store = StoreBuilder::new( + engine.clone(), + WasiVersion::Preview1, + CachedGraphRegistry::new(), + ) + .build(NoStats) + .expect("build store"); assert_eq!( instances::live(), before + 1, "building a store must count a live instance" ); - let second = StoreBuilder::new(engine, WasiVersion::Preview1) + let second = StoreBuilder::new(engine, WasiVersion::Preview1, CachedGraphRegistry::new()) .build(NoStats) .expect("build store"); assert_eq!(instances::live(), before + 2); @@ -680,4 +695,61 @@ mod tests { "dropping the store must release the count" ); } + + /// An app without wasi-nn env preloads nothing, and a store's wasi-nn + /// context then comes from the preloaded (empty) set without touching the + /// loader. (`make_wasi_nn` is called directly: building a whole `Store` + /// here would race the live-instance counts asserted by the test above.) + #[test] + fn preload_wasi_nn_without_nn_env_is_a_noop() { + let builder = StoreBuilder::new( + make_engine(), + WasiVersion::Preview1, + CachedGraphRegistry::new(), + ) + .set_env(&[("UNRELATED", "1")]) + .preload_wasi_nn() + .expect("preload"); + assert_eq!(builder.nn_graphs.as_ref().map(Vec::len), Some(0)); + builder + .make_wasi_nn() + .expect("wasi-nn ctx from preloaded graphs"); + } + + /// A builder that never preloaded (proxy-wasm) builds no wasi-nn context, + /// whatever the env says: the engine would not link it anyway. + #[test] + fn make_wasi_nn_is_none_without_preload() { + let builder = StoreBuilder::new( + make_engine(), + WasiVersion::Preview1, + CachedGraphRegistry::new(), + ) + .set_env(&[ + ("FASTEDGE_NN_BACKENDS", "openvino"), + ("FASTEDGE_NN_OPENVINO_MODEL", "/no/such/model-dir"), + ]); + assert!(builder.has_wasi_nn_env()); + assert!(builder.make_wasi_nn().is_none()); + } + + /// A graph the executor cannot load fails executor creation — with the + /// offending directory in the error — instead of every request later on. + #[test] + fn preload_wasi_nn_reports_an_unloadable_graph_at_executor_creation() { + let error = StoreBuilder::new( + make_engine(), + WasiVersion::Preview1, + CachedGraphRegistry::new(), + ) + .set_env(&[ + ("FASTEDGE_NN_BACKENDS", "openvino"), + ("FASTEDGE_NN_OPENVINO_MODEL", "/no/such/model-dir"), + ]) + .preload_wasi_nn() + .err() + .expect("missing model dir must fail") + .to_string(); + assert!(error.contains("/no/such/model-dir"), "{error}"); + } } diff --git a/crates/runtime/src/trace.rs b/crates/runtime/src/trace.rs new file mode 100644 index 0000000..cbdbd27 --- /dev/null +++ b/crates/runtime/src/trace.rs @@ -0,0 +1,60 @@ +//! W3C trace-context helpers. + +use rand::RngCore; +use smol_str::SmolStr; +use std::fmt::Write; + +/// Generate a fresh W3C `traceparent`: `00---01`, with a +/// random 16-byte trace-id and 8-byte parent-id and the sampled flag set. +/// +/// Used as the fallback when an inbound request does not carry a `traceparent` +/// header, so every record on the trace stream has a valid, joinable id. +pub fn new_traceparent() -> SmolStr { + let mut rng = rand::thread_rng(); + let mut trace_id = [0u8; 16]; + let mut span_id = [0u8; 8]; + rng.fill_bytes(&mut trace_id); + rng.fill_bytes(&mut span_id); + // The all-zero value is invalid per the spec; nudge it (astronomically + // unlikely, but effectively free to guard). + if trace_id.iter().all(|&b| b == 0) { + trace_id[0] = 1; + } + if span_id.iter().all(|&b| b == 0) { + span_id[0] = 1; + } + + // "00-" + 32 + "-" + 16 + "-01" = 55 chars. + let mut s = String::with_capacity(55); + s.push_str("00-"); + for b in trace_id { + let _ = write!(s, "{:02x}", b); + } + s.push('-'); + for b in span_id { + let _ = write!(s, "{:02x}", b); + } + s.push_str("-01"); + SmolStr::new(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn well_formed_and_unique() { + let tp = new_traceparent(); + let parts: Vec<&str> = tp.split('-').collect(); + assert_eq!(parts.len(), 4, "four dash-separated fields: {tp}"); + assert_eq!(parts[0], "00"); + assert_eq!(parts[1].len(), 32); + assert_eq!(parts[2].len(), 16); + assert_eq!(parts[3], "01"); + assert!(parts[1].bytes().all(|c| c.is_ascii_hexdigit())); + assert!(parts[2].bytes().all(|c| c.is_ascii_hexdigit())); + assert_ne!(parts[1], "0".repeat(32)); + assert_ne!(parts[2], "0".repeat(16)); + assert_ne!(new_traceparent(), new_traceparent()); + } +} diff --git a/crates/runtime/src/util/metrics.rs b/crates/runtime/src/util/metrics.rs index 7d5b31a..90baff4 100644 --- a/crates/runtime/src/util/metrics.rs +++ b/crates/runtime/src/util/metrics.rs @@ -1,70 +1,61 @@ -use lazy_static::lazy_static; -use prometheus::{ - self, HistogramVec, IntCounterVec, register_histogram_vec, register_int_counter_vec, -}; +//! App-call metrics hook. +//! +//! The runtime reports every app-call outcome through a process-global sink so +//! that embedders (e.g. the FastEdge server) can export them however they like +//! (Prometheus, etc.) without this crate depending on a metrics backend. When +//! no sink is installed (e.g. the `fastedge-run` CLI) reporting is a no-op. + +use std::sync::OnceLock; use crate::AppResult; -lazy_static! { - static ref TOTAL_COUNT: IntCounterVec = register_int_counter_vec!( - "fastedge_call_count", - "Total number of app calls.", - &["executor"] - ) - .unwrap(); - static ref ERROR_COUNT: IntCounterVec = register_int_counter_vec!( - "fastedge_error_total_count", - "Total number of failed app calls, by executor and failure reason \ - (counter; the non-conventional *_count suffix is kept for backwards compatibility).", - &["executor", "reason"] - ) - .unwrap(); - static ref REQUEST_DURATION: HistogramVec = register_histogram_vec!( - "fastedge_request_duration", - "Request duration", - &["executor"], - vec![ - 0.0001, 0.0002, 0.0003, 0.0005, 0.001, 0.002, 0.003, 0.005, 0.01, 0.02, 0.03, 0.05, - 0.1, 0.2, 0.3, 0.5, 1.0, 10.0 - ] - ) - .unwrap(); - static ref MEMORY_USAGE: IntCounterVec = register_int_counter_vec!( - "fastedge_wasm_memory_used", - "Cumulative WASM linear-memory bytes consumed by app calls (counter, NOT a \ - current-usage gauge; the name is kept for backwards compatibility). \ - rate() = memory-zeroing throughput; divide by rate(fastedge_call_count) \ - for average memory per request.", - &["executor"] - ) - .unwrap(); +/// Where in an app call the outcome was decided. +/// +/// Refines the metrics `outcome` label only — stats (`fail_reason`) always +/// carry the plain [`AppResult`], so the ClickHouse encoding is untouched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CallPhase { + /// Guest initialisation (`_initialize`, `on_context_create`): no request + /// callback had run yet. + Init, + /// A request callback (`on_request_headers`, body chunks, `on_log`, …), or + /// a rejection before the guest was involved at all. + Request, } -pub fn metrics(result: AppResult, label: &[&str], duration: Option, memory_used: Option) { - TOTAL_COUNT.with_label_values(label).inc(); +/// Sink invoked for every app call: outcome, the phase it was decided in, +/// executor label(s), duration in microseconds and WASM linear memory used in +/// bytes. +pub type MetricsSink = fn( + result: AppResult, + phase: CallPhase, + label: &[&str], + duration: Option, + memory_used: Option, +); - if result != AppResult::SUCCESS { - let mut values: Vec<&str> = label.iter().map(|v| *v).collect(); - match result { - AppResult::UNKNOWN => values.push("unknown"), - AppResult::TIMEOUT => values.push("timeout"), - AppResult::OOM => values.push("oom"), - AppResult::OTHER => values.push("other"), - AppResult::OVERLOADED => values.push("overloaded"), - AppResult::RATE_LIMITED => values.push("ratelimited"), - AppResult::DISABLED => values.push("disabled"), - _ => {} - }; +static SINK: OnceLock = OnceLock::new(); - ERROR_COUNT.with_label_values(values.as_slice()).inc(); - } +/// Install the process-global metrics sink. Subsequent calls are ignored. +pub fn set_sink(sink: MetricsSink) { + let _ = SINK.set(sink); +} - if let Some(duration) = duration { - REQUEST_DURATION - .with_label_values(label) - .observe((duration as f64) / 1_000_000.0); - } - if let Some(memory_used) = memory_used { - MEMORY_USAGE.with_label_values(label).inc_by(memory_used); +/// Report one app call decided in the request phase (no-op when no sink is +/// installed). The common case; see [`metrics_in_phase`] for the rest. +pub fn metrics(result: AppResult, label: &[&str], duration: Option, memory_used: Option) { + metrics_in_phase(result, CallPhase::Request, label, duration, memory_used); +} + +/// Report one app call, stating the phase its outcome was decided in. +pub fn metrics_in_phase( + result: AppResult, + phase: CallPhase, + label: &[&str], + duration: Option, + memory_used: Option, +) { + if let Some(sink) = SINK.get() { + sink(result, phase, label, duration, memory_used); } } diff --git a/crates/runtime/src/util/mod.rs b/crates/runtime/src/util/mod.rs index 41c2dc3..d14ad41 100644 --- a/crates/runtime/src/util/mod.rs +++ b/crates/runtime/src/util/mod.rs @@ -1,3 +1,2 @@ -#[cfg(feature = "metrics")] pub mod metrics; pub mod stats; diff --git a/crates/runtime/src/util/stats.rs b/crates/runtime/src/util/stats.rs index b4a7cfa..2cb5712 100644 --- a/crates/runtime/src/util/stats.rs +++ b/crates/runtime/src/util/stats.rs @@ -44,6 +44,10 @@ pub trait StatsVisitor: ReadStats + UserDiagStats + ExtRequestStats + Send + Syn fn get_memory_used(&self) -> u64; /// Register cdn phase fn cdn_phase(&self, phase: CdnPhase); + /// Mark the record as not to be persisted to the stats store (e.g. a + /// synthetic probe that must not count as traffic). Counters keep being + /// collected and Prometheus metrics / logs are unaffected. No-op by default. + fn discard(&self) {} } impl StatsTimer { @@ -144,6 +148,8 @@ mod tests { fn count_kv_byod_read(&self, value: i32) { self.byod_reads.fetch_add(value, Ordering::Relaxed); } + + fn count_kv_read_cached(&self) {} } impl UserDiagStats for MockStatsVisitor { diff --git a/src/context.rs b/src/context.rs index 1a9a990..3b0dae5 100644 --- a/src/context.rs +++ b/src/context.rs @@ -14,8 +14,8 @@ use runtime::app::{KvStoreOption, SecretOption}; use runtime::logger::{Console, Logger}; use runtime::util::stats::{CdnPhase, StatsVisitor}; use runtime::{ - App, ContextT, ExecutorCache, PreCompiledLoader, Router, WasiVersion, WasmEngine, - componentize_if_necessary, + App, CachedGraphRegistry, ContextT, ExecutorCache, PreCompiledLoader, Router, WasiVersion, + WasmEngine, componentize_if_necessary, }; use secret::SecretStore; use smol_str::SmolStr; @@ -34,19 +34,20 @@ pub struct Context { pub(crate) engine: Engine, pub(crate) app: Option, pub(crate) backend: Backend>, - pub(crate) wasm_bytes: Vec, + pub(crate) wasm_bytes: Arc<[u8]>, pub(crate) wasi_http: bool, pub(crate) cache_backend: Arc, + pub(crate) graph_registry: CachedGraphRegistry, } impl PreCompiledLoader for Context { fn load_component(&self, _id: u64) -> anyhow::Result { let wasm_sample = componentize_if_necessary(&self.wasm_bytes)?; - Component::new(&self.engine, wasm_sample) + Ok(Component::new(&self.engine, wasm_sample)?) } fn load_module(&self, _id: u64) -> anyhow::Result { - unreachable!("") + unreachable!("fastedge-run loads components only, not core wasm modules") } } @@ -69,6 +70,10 @@ impl ContextT for Context { &self.engine } + fn graph_registry(&self) -> CachedGraphRegistry { + self.graph_registry.clone() + } + fn make_secret_store(&self, secrets: &Vec) -> anyhow::Result { let mut secret_impl = SecretImpl::default(); for s in secrets { @@ -94,21 +99,22 @@ impl ContextT for Context { &self, _request_id: &SmolStr, _app: &SmolStr, + _caller_ip: std::net::Ipv4Addr, _cfg: &App, ) -> Arc { Arc::new(StatsStub::default()) } } -impl ExecutorFactory>> for Context { - type Executor = RunExecutor; - - async fn get_executor( +impl Context { + /// Builds an executor for `app`. Blocking and CPU-bound (component compilation, + /// wasi-nn graph preload), so it must run on the blocking pool. + fn make_executor( &self, name: SmolStr, app: &App, engine: &WasmEngine>>, - ) -> anyhow::Result { + ) -> anyhow::Result { let mut dictionary = Dictionary::new(); for (k, v) in app.env.iter() { dictionary.insert(k.to_string(), v.to_string()); @@ -124,6 +130,7 @@ impl ExecutorFactory>> for Context { let store_builder = engine .store_builder(version) .set_env(&env) + .preload_wasi_nn()? .max_memory_size(app.mem_limit) .max_epoch_ticks(app.max_duration) .logger(logger) @@ -154,6 +161,24 @@ impl ExecutorFactory>> for Context { } } +impl ExecutorFactory>> for Context { + type Executor = RunExecutor; + + async fn get_executor( + &self, + name: SmolStr, + app: &App, + engine: &WasmEngine>>, + ) -> anyhow::Result { + // Building an executor compiles the component and may load a multi-GiB wasi-nn + // graph, so keep it off the async worker (as the server's `get_executor` does). + let this = self.clone(); + let app = app.clone(); + let engine = engine.clone(); + tokio::task::spawn_blocking(move || this.make_executor(name, &app, &engine)).await? + } +} + impl ExecutorCache for Context { async fn remove(&self, _name: &str) { unreachable!() @@ -190,6 +215,8 @@ impl ReadStats for StatsStub { fn count_kv_read(&self, _value: i32) {} fn count_kv_byod_read(&self, _value: i32) {} + + fn count_kv_read_cached(&self) {} } impl UserDiagStats for StatsStub { diff --git a/src/key_value.rs b/src/key_value.rs index 47f5250..183636d 100644 --- a/src/key_value.rs +++ b/src/key_value.rs @@ -32,6 +32,7 @@ mod tests { impl ReadStats for NoOpStats { fn count_kv_read(&self, _: i32) {} fn count_kv_byod_read(&self, _: i32) {} + fn count_kv_read_cached(&self) {} } fn make_kv_option(name: &str, param: &str) -> KvStoreOption { diff --git a/src/main.rs b/src/main.rs index 09a0bfd..cfe49de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use hyper_tls::HttpsConnector; use hyper_util::client::legacy::connect::HttpConnector; use runtime::app::{CacheMode, KvStoreOption, Status}; use runtime::service::{Service, ServiceBuilder}; -use runtime::{App, SecretValue, WasmConfig}; +use runtime::{App, CachedGraphRegistry, SecretValue, WasmConfig}; use smol_str::{SmolStr, ToSmolStr}; use std::collections::HashMap; use std::path::PathBuf; @@ -192,9 +192,10 @@ async fn main() -> anyhow::Result<()> { engine, app: Some(cli_app), backend, - wasm_bytes, + wasm_bytes: wasm_bytes.into(), wasi_http: run.wasi_http.unwrap_or_default(), cache_backend: Arc::new(MemoryCacheBackend::new()), + graph_registry: CachedGraphRegistry::new(), }; let http: HttpService = ServiceBuilder::new(context).build()?;