From 8bc72a47ed8087c3df79ae62b21543badb602361 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 31 Jul 2026 11:48:26 +0300 Subject: [PATCH 01/29] feat(stats): refactor stats handling to include caller IP and improve data structure --- crates/http-service/src/executor/http.rs | 2 +- crates/http-service/src/lib.rs | 8 +++++++- crates/runtime/src/lib.rs | 3 ++- src/context.rs | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index 2a541cf..d6128df 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -353,7 +353,7 @@ mod tests { fn new_stats_row( &self, _request_id: &SmolStr, - _app: &SmolStr, + _caller_ip: std::net::Ipv4Addr, _cfg: &App, ) -> Arc { Arc::new(TestStats) diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index 1d06ce4..afac8fb 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -358,7 +358,13 @@ where } }; - let stats = self.context.new_stats_row(&traceparent, &app_name, &cfg); + let caller_ip = request + .headers() + .get(crate::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, caller_ip, &cfg); let response = match executor.execute(request, stats.clone()).await { Ok(mut response) => { diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 81054f2..df14f9c 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,6 +1,7 @@ use crate::app::KvStoreOption; use crate::store::HasStats; use http_backend::stats::ExtStatsTimer; +use std::net::Ipv4Addr; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; @@ -418,7 +419,7 @@ pub trait ContextT { fn new_stats_row( &self, request_id: &SmolStr, - app: &SmolStr, + caller_ip: Ipv4Addr, cfg: &App, ) -> Arc; } diff --git a/src/context.rs b/src/context.rs index 8163a3d..fc82970 100644 --- a/src/context.rs +++ b/src/context.rs @@ -93,7 +93,7 @@ impl ContextT for Context { fn new_stats_row( &self, _request_id: &SmolStr, - _app: &SmolStr, + _caller_ip: std::net::Ipv4Addr, _cfg: &App, ) -> Arc { Arc::new(StatsStub::default()) From 43607a115e859546e5f08dd4232aa2dbcee57f5a Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 21 Aug 2026 12:32:04 +0300 Subject: [PATCH 02/29] feat(stats): revert app_name parameter to new_stats_row --- crates/http-service/src/executor/http.rs | 1 + crates/http-service/src/lib.rs | 4 +++- crates/runtime/src/lib.rs | 1 + src/context.rs | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index d6128df..acd10a3 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -353,6 +353,7 @@ mod tests { fn new_stats_row( &self, _request_id: &SmolStr, + _app: &SmolStr, _caller_ip: std::net::Ipv4Addr, _cfg: &App, ) -> Arc { diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index afac8fb..13c9ffe 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -364,7 +364,9 @@ where .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, caller_ip, &cfg); + let stats = self + .context + .new_stats_row(&traceparent, &app_name, caller_ip, &cfg); let response = match executor.execute(request, stats.clone()).await { Ok(mut response) => { diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index df14f9c..91bc960 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -419,6 +419,7 @@ pub trait ContextT { fn new_stats_row( &self, request_id: &SmolStr, + app: &SmolStr, caller_ip: Ipv4Addr, cfg: &App, ) -> Arc; diff --git a/src/context.rs b/src/context.rs index fc82970..e12a664 100644 --- a/src/context.rs +++ b/src/context.rs @@ -93,6 +93,7 @@ impl ContextT for Context { fn new_stats_row( &self, _request_id: &SmolStr, + _app: &SmolStr, _caller_ip: std::net::Ipv4Addr, _cfg: &App, ) -> Arc { From dff3ef52d08efe58b4dedaa83c25b435634086e1 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Mon, 24 Aug 2026 10:29:44 +0300 Subject: [PATCH 03/29] feat(stats): add cached read tracking to ReadStats and MeteredStore --- crates/http-service/src/executor/http.rs | 2 ++ crates/http-service/src/state.rs | 1 + crates/key-value-store/src/lib.rs | 24 ++++++++++++++++++++++++ crates/runtime/src/store.rs | 1 + crates/runtime/src/util/stats.rs | 2 ++ src/context.rs | 2 ++ src/key_value.rs | 1 + 7 files changed, 33 insertions(+) diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index acd10a3..bfd5986 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -283,6 +283,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 { diff --git a/crates/http-service/src/state.rs b/crates/http-service/src/state.rs index 97815ef..ad62f6f 100644 --- a/crates/http-service/src/state.rs +++ b/crates/http-service/src/state.rs @@ -236,6 +236,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/src/lib.rs b/crates/key-value-store/src/lib.rs index 05d4336..cff4c1f 100644 --- a/crates/key-value-store/src/lib.rs +++ b/crates/key-value-store/src/lib.rs @@ -13,10 +13,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, @@ -47,6 +65,10 @@ pub trait ReadStats: Sync + Send { 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)] @@ -367,6 +389,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/runtime/src/store.rs b/crates/runtime/src/store.rs index 0435c92..e36f915 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -616,6 +616,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) {} diff --git a/crates/runtime/src/util/stats.rs b/crates/runtime/src/util/stats.rs index b4a7cfa..ca5abd2 100644 --- a/crates/runtime/src/util/stats.rs +++ b/crates/runtime/src/util/stats.rs @@ -144,6 +144,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 e12a664..0c2d6d0 100644 --- a/src/context.rs +++ b/src/context.rs @@ -191,6 +191,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 { From 5e9ff8ec6f17f51d86d82149ea439949facd776b Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Tue, 1 Sep 2026 16:18:18 +0300 Subject: [PATCH 04/29] Add bounded retry with backoff for Redis read operations Reads (get/exists/zrange_by_score/scan/zscan/bf_exists) in both the KV-store and cache-backend Redis clients now retry up to 2 times with capped exponential backoff (10ms/20ms, max 50ms) on top of the existing connection-manager reconnect. Kept deliberately small and bounded to avoid reintroducing the pile-up behavior behind the 2026-07-09 congestion-collapse incident. Mutating operations (incr/set/delete/ expire/clean_index) are left untouched since a lost-response retry could double-apply them. Co-Authored-By: Claude Sonnet 5 --- crates/key-value-store/Cargo.toml | 1 + crates/key-value-store/src/redis_impl.rs | 110 ++++++++++++++++++----- 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/crates/key-value-store/Cargo.toml b/crates/key-value-store/Cargo.toml index 5b5f144..77860cf 100644 --- a/crates/key-value-store/Cargo.toml +++ b/crates/key-value-store/Cargo.toml @@ -17,6 +17,7 @@ slab = "0.4" async-trait = "0.1" smol_str = {workspace = true} tracing = "0.1" +tokio = { workspace = true } redis = { version = "1.2", features = ["aio", "tokio-comp", "connection-manager", "tokio-native-tls-comp"], optional = true} [dev-dependencies] diff --git a/crates/key-value-store/src/redis_impl.rs b/crates/key-value-store/src/redis_impl.rs index e564100..6ea80fd 100644 --- a/crates/key-value-store/src/redis_impl.rs +++ b/crates/key-value-store/src/redis_impl.rs @@ -16,6 +16,43 @@ 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); + +fn redis_read_retry_delay(attempt: usize) -> Duration { + let scale = 1u32 << (attempt.saturating_sub(1) as u32); + (REDIS_READ_RETRY_BASE_DELAY * scale).min(REDIS_READ_RETRY_MAX_DELAY) +} + +/// 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). +async fn retry_read(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 => { + attempt += 1; + tracing::debug!(attempt, cause = ?error, "kv-store: redis read retry"); + tokio::time::sleep(redis_read_retry_delay(attempt)).await; + } + Err(error) => return Err(error), + } + } +} + /// Build the fail-fast connection-manager config for KV-store Redis connections. fn connection_manager_config() -> ConnectionManagerConfig { ConnectionManagerConfig::new() @@ -78,10 +115,12 @@ 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(|| async { self.conn().get(key).await }) + .await + .map_err(|error| { + tracing::warn!(cause = ?error, key, "kv-store: redis get"); + Error::InternalError + }) } async fn zrange_by_score( @@ -90,8 +129,7 @@ impl Store for RedisStore { min: f64, max: f64, ) -> Result, Error> { - self.conn() - .zrangebyscore_withscores(key, min, max) + retry_read(|| async { self.conn().zrangebyscore_withscores(key, min, max).await }) .await .map_err(|error| { tracing::warn!(cause = ?error, key, min, max, "kv-store: redis zrangebyscore"); @@ -101,10 +139,21 @@ impl Store for RedisStore { 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) if attempt < REDIS_READ_RETRIES => { + attempt += 1; + tracing::debug!(attempt, cause = ?error, pattern, "kv-store: redis scan_match retry"); + tokio::time::sleep(redis_read_retry_delay(attempt)).await; + } + Err(error) => { + tracing::warn!(cause = ?error, pattern, "kv-store: redis scan_match"); + return Err(Error::InternalError); + } + } + }; let mut ret = vec![]; while let Some(element) = it.next_item().await { ret.push(element.map_err(|error| { @@ -117,11 +166,21 @@ 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) if attempt < REDIS_READ_RETRIES => { + attempt += 1; + tracing::debug!(attempt, cause = ?error, key, pattern, "kv-store: redis zscan_match retry"); + tokio::time::sleep(redis_read_retry_delay(attempt)).await; + } + Err(error) => { + tracing::warn!(cause = ?error, key, pattern, "kv-store: redis zscan_match"); + return Err(Error::InternalError); + } + } + }; let mut ret = vec![]; while let Some(element) = it.next_item().await { ret.push(element.map_err(|error| { @@ -133,14 +192,17 @@ impl Store for RedisStore { } 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| { - tracing::warn!(cause = ?error, key, item, "kv-store: redis bf_exists"); - Error::InternalError - }) + retry_read(|| async { + redis::cmd("BF.EXISTS") + .arg(key) + .arg(item) + .query_async(&mut self.conn()) + .await + }) + .await + .map_err(|error| { + tracing::warn!(cause = ?error, key, item, "kv-store: redis bf_exists"); + Error::InternalError + }) } } From 43c737689d2f420d9a6290a7a3e010d8948e9793 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Wed, 2 Sep 2026 09:57:00 +0300 Subject: [PATCH 05/29] refactor: improve Redis scan_index function to use AsyncIter and enhance error handling --- crates/key-value-store/Cargo.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/key-value-store/Cargo.toml b/crates/key-value-store/Cargo.toml index 77860cf..168ad06 100644 --- a/crates/key-value-store/Cargo.toml +++ b/crates/key-value-store/Cargo.toml @@ -8,17 +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" -tokio = { workspace = true } -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"] } From 3edca81246eb5ba739164f9ff62b87d8cd8b7b27 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 4 Sep 2026 10:35:27 +0300 Subject: [PATCH 06/29] refactor(metrics): extract Prometheus backend from fastedge-lib into server Remove the 'metrics' cargo feature and the prometheus/lazy_static dependencies from the public fastedge-lib crates: - runtime::util::metrics is now a backend-agnostic hook: embedders install a process-global sink via set_sink(); with none installed (fastedge-run CLI) reporting is a no-op - runtime::instances keeps live/peak counts in plain atomics; flush_peak() now returns the drained peak instead of setting a gauge - AppResult variants and http-service call sites are no longer feature-gated The Prometheus implementation (fastedge_call_count, fastedge_error_total_count, fastedge_request_duration, fastedge_wasm_memory_used, fastedge_wasm_instances_live/peak) moves to server/src/metrics.rs; the sink is installed at startup and the instance gauges are set on each scrape (same semantics). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE.md | 2 +- Cargo.lock | 67 ----------------- crates/http-service/Cargo.toml | 1 - crates/http-service/src/lib.rs | 7 -- crates/runtime/Cargo.toml | 5 -- crates/runtime/src/instances.rs | 111 +++++++++++------------------ crates/runtime/src/lib.rs | 4 -- crates/runtime/src/store.rs | 4 -- crates/runtime/src/util/metrics.rs | 83 ++++++--------------- crates/runtime/src/util/mod.rs | 1 - 10 files changed, 63 insertions(+), 222 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d25bf3b..417ee2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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..005c0c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,12 +1527,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" @@ -2541,65 +2535,6 @@ 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" @@ -2907,9 +2842,7 @@ dependencies = [ "http", "http-backend", "key-value-store", - "lazy_static", "moka", - "prometheus", "secret", "serde", "serde_json", diff --git a/crates/http-service/Cargo.toml b/crates/http-service/Cargo.toml index 8abc003..e5b4b8b 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 } diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index 262e182..a305f89 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -15,7 +15,6 @@ 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::{ @@ -38,7 +37,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; @@ -277,7 +275,6 @@ where // 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 +304,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(); @@ -350,7 +346,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" @@ -371,7 +366,6 @@ where let response = match executor.execute(request, stats.clone()).await { Ok(mut response) => { - #[cfg(feature = "metrics")] metrics::metrics( AppResult::SUCCESS, &["http"], @@ -389,7 +383,6 @@ where stats.fail_reason(fail_reason as i32); tracing::debug!(?fail_reason, ?traceparent, "stats"); - #[cfg(feature = "metrics")] metrics::metrics( fail_reason, HTTP_LABEL, diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index d65243c..1a7f9a9 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -5,9 +5,6 @@ edition.workspace = true publish.workspace = true authors.workspace = true -[features] -metrics = ["prometheus", "lazy_static"] - [dependencies] anyhow = { workspace = true } tokio = { workspace = true } @@ -35,8 +32,6 @@ bytes = "1.11" 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" diff --git a/crates/runtime/src/instances.rs b/crates/runtime/src/instances.rs index 9dd1e89..da12d13 100644 --- a/crates/runtime/src/instances.rs +++ b/crates/runtime/src/instances.rs @@ -14,78 +14,49 @@ //! 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 reading [`live`] and +//! draining [`flush_peak`] on each scrape. + +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(); - } - - /// Currently live instances. Test/diagnostic accessor. - pub fn live() -> i64 { - WASM_INSTANCES_LIVE.get() - } - - /// Peak since the previous [`flush_peak`]. Test/diagnostic accessor. - pub fn peak() -> i64 { - PEAK.load(Ordering::Relaxed) - } +fn release() { + LIVE.fetch_sub(1, 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); - } +/// Currently live instances. +pub fn live() -> i64 { + LIVE.load(Ordering::Relaxed) } -#[cfg(not(feature = "metrics"))] -mod imp { - pub(super) fn acquire() {} - pub(super) fn release() {} - pub fn flush_peak() {} +/// Peak since the previous [`flush_peak`]. Test/diagnostic accessor. +pub fn peak() -> i64 { + PEAK.load(Ordering::Relaxed) } -pub use imp::flush_peak; -#[cfg(feature = "metrics")] -pub use imp::{live, 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. +pub fn flush_peak() -> i64 { + PEAK.swap(0, Ordering::Relaxed).max(live()) +} /// RAII counter for one live WASM instance. /// @@ -97,7 +68,7 @@ pub struct LiveInstanceGuard; impl LiveInstanceGuard { pub fn new() -> Self { - imp::acquire(); + acquire(); LiveInstanceGuard } } @@ -110,15 +81,15 @@ 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::*; - /// 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() { diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index bdbcb6e..b85a515 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -58,19 +58,15 @@ const PREVIEW1_ADAPTER: &[u8] = include_bytes!("adapters/wasi_snapshot_preview1. #[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, } diff --git a/crates/runtime/src/store.rs b/crates/runtime/src/store.rs index e36f915..3bc9932 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -602,10 +602,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}; @@ -639,7 +637,6 @@ mod tests { } } - #[cfg(feature = "metrics")] impl HasStats for NoStats { fn get_stats(&self) -> Arc { Arc::new(NoStats) @@ -649,7 +646,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; diff --git a/crates/runtime/src/util/metrics.rs b/crates/runtime/src/util/metrics.rs index 7d5b31a..25b994e 100644 --- a/crates/runtime/src/util/metrics.rs +++ b/crates/runtime/src/util/metrics.rs @@ -1,70 +1,29 @@ -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 crate::AppResult; +use std::sync::OnceLock; -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(); -} +use crate::AppResult; -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, executor label(s), duration in +/// microseconds and WASM linear memory used in bytes. +pub type MetricsSink = + fn(result: AppResult, 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 to the installed sink (no-op when none is installed). +pub fn metrics(result: AppResult, label: &[&str], duration: Option, memory_used: Option) { + if let Some(sink) = SINK.get() { + sink(result, 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; From 852772d36f87d301c2495d916f598060edc41fc6 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 4 Sep 2026 11:35:23 +0300 Subject: [PATCH 07/29] refactor(metrics): improve error labeling logic and ensure peak >= live consistency in `runtime::instances` --- crates/runtime/src/instances.rs | 88 +++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/runtime/src/instances.rs b/crates/runtime/src/instances.rs index da12d13..ddbd0e2 100644 --- a/crates/runtime/src/instances.rs +++ b/crates/runtime/src/instances.rs @@ -16,8 +16,10 @@ //! 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 reading [`live`] and -//! draining [`flush_peak`] on each scrape. +//! `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}; @@ -54,8 +56,36 @@ pub fn peak() -> i64 { /// 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 { - PEAK.swap(0, Ordering::Relaxed).max(live()) + let (peak, _live) = flush_peak_and_live(); + 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()) +} + +/// 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. @@ -88,11 +118,22 @@ impl Drop for LiveInstanceGuard { #[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 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(); @@ -110,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}"); + } } From 5fb9116f4a677d7af92caea4680a9e50700f46a2 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Mon, 7 Sep 2026 11:58:44 +0300 Subject: [PATCH 08/29] Add KV read cache, retry and latency metrics Expose the four missing key-value read metrics on the Prometheus scrape: - fastedge_kv_reads_total{result="miss"|"hit"} splits reads into direct (backing-store) and in-process cache hits. Commands that are never cached (scan/zscan/zrange_by_score/bf_exists) count as misses. - fastedge_redis_read_retries_total{store,command} counts retried read attempts only, so rate() gives retries since the previous scrape. - fastedge_redis_read_duration_seconds{store,command} times uncached reads; the average since the previous scrape is rate(_sum)/rate(_count). Cache hits are excluded (in-process lookup) and failed reads are not observed, since their timeout-dominated duration would skew the latency of served reads. The key-value store lives in the fastedge-lib workspace and must stay free of a metrics backend, so it reports retries through a new set_read_retry_observer hook that the server installs at startup. All new series are materialised at 0 by redis_metrics::init() and are documented with PromQL in docs/METRICS.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/key-value-store/src/lib.rs | 53 ++++++++++++++++++++++++ crates/key-value-store/src/redis_impl.rs | 26 +++++++----- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/crates/key-value-store/src/lib.rs b/crates/key-value-store/src/lib.rs index cff4c1f..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; @@ -59,6 +60,37 @@ 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 @@ -264,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, diff --git a/crates/key-value-store/src/redis_impl.rs b/crates/key-value-store/src/redis_impl.rs index 6ea80fd..1136ee5 100644 --- a/crates/key-value-store/src/redis_impl.rs +++ b/crates/key-value-store/src/redis_impl.rs @@ -33,8 +33,9 @@ fn redis_read_retry_delay(attempt: usize) -> Duration { /// 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). -async fn retry_read(mut op: F) -> Result +/// borrows it across the loop). Each retry is reported to the installed +/// [`crate::ReadRetryObserver`] under `command`. +async fn retry_read(command: &'static str, mut op: F) -> Result where F: FnMut() -> Fut, Fut: std::future::Future>, @@ -45,6 +46,7 @@ where Ok(value) => return Ok(value), Err(error) if attempt < REDIS_READ_RETRIES => { attempt += 1; + crate::note_read_retry(command); tracing::debug!(attempt, cause = ?error, "kv-store: redis read retry"); tokio::time::sleep(redis_read_retry_delay(attempt)).await; } @@ -115,7 +117,7 @@ impl RedisStore { #[async_trait::async_trait] impl Store for RedisStore { async fn get(&self, key: &str) -> Result, Error> { - retry_read(|| async { self.conn().get(key).await }) + retry_read(crate::CMD_GET, || async { self.conn().get(key).await }) .await .map_err(|error| { tracing::warn!(cause = ?error, key, "kv-store: redis get"); @@ -129,12 +131,14 @@ impl Store for RedisStore { min: f64, max: f64, ) -> Result, Error> { - retry_read(|| async { self.conn().zrangebyscore_withscores(key, min, max).await }) - .await - .map_err(|error| { - tracing::warn!(cause = ?error, key, min, max, "kv-store: redis zrangebyscore"); - Error::InternalError - }) + retry_read(crate::CMD_ZRANGE_BY_SCORE, || async { + self.conn().zrangebyscore_withscores(key, min, max).await + }) + .await + .map_err(|error| { + tracing::warn!(cause = ?error, key, min, max, "kv-store: redis zrangebyscore"); + Error::InternalError + }) } async fn scan(&self, pattern: &str) -> Result, Error> { @@ -145,6 +149,7 @@ impl Store for RedisStore { Ok(it) => break it, Err(error) if attempt < REDIS_READ_RETRIES => { attempt += 1; + crate::note_read_retry(crate::CMD_SCAN); tracing::debug!(attempt, cause = ?error, pattern, "kv-store: redis scan_match retry"); tokio::time::sleep(redis_read_retry_delay(attempt)).await; } @@ -172,6 +177,7 @@ impl Store for RedisStore { Ok(it) => break it, Err(error) if attempt < REDIS_READ_RETRIES => { attempt += 1; + crate::note_read_retry(crate::CMD_ZSCAN); tracing::debug!(attempt, cause = ?error, key, pattern, "kv-store: redis zscan_match retry"); tokio::time::sleep(redis_read_retry_delay(attempt)).await; } @@ -192,7 +198,7 @@ impl Store for RedisStore { } async fn bf_exists(&self, key: &str, item: &str) -> Result { - retry_read(|| async { + retry_read(crate::CMD_BF_EXISTS, || async { redis::cmd("BF.EXISTS") .arg(key) .arg(item) From 1a8f1a830491cd812a6cc47f2f616a9ae670fb99 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Wed, 9 Sep 2026 10:47:32 +0300 Subject: [PATCH 09/29] fix(http-service): preserve error chain so wasi-http timeouts return 532 When the guest trapped before invoking `response-outparam::set`, the error was rebuilt with `bail!("...: {e:?}")`, which flattens the cause into a string and drops the source chain. `map_err`/`fail_reason_of` classify errors by downcasting `root_cause()` to `wasmtime::Trap` or `Elapsed`, so every such failure fell through to the generic branch and returned 530/INTERNAL_STATUS_EXECUTE_ERROR instead of 532/3010 - even though the stats row was correctly recorded as TIMEOUT. Use `.context()` to attach the message while keeping the chain intact. Also stop logging the same backtrace twice: the spawned task logged "incoming handler" and the error was then logged again as "execute" after propagating up. The task-side log now only fires when the error cannot propagate (response headers already flushed), which was the only case it was needed for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/http-service/src/executor/wasi_http.rs | 34 +++++++++++++++---- crates/http-service/src/lib.rs | 21 ++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/http-service/src/executor/wasi_http.rs b/crates/http-service/src/executor/wasi_http.rs index 90a083c..daab131 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}; @@ -178,7 +178,6 @@ where 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 @@ -203,11 +202,27 @@ where ); match receiver.await { - Ok(Ok(response)) => { - stats.status_code(response.status().as_u16()); - Ok(response) + Ok(inner) => { + // The guest already invoked `response-outparam::set`, so a later + // failure of the task (e.g. an epoch interrupt while streaming the + // body) is never propagated to the caller. Log it here - this is + // the only place it can be reported. + tokio::task::spawn( + async move { + if let Ok(Err(e)) = task.await { + tracing::warn!(cause=?e, "incoming handler"); + } + } + .in_current_span(), + ); + match inner { + Ok(response) => { + stats.status_code(response.status().as_u16()); + Ok(response) + } + Err(error) => Err(error.into()), + } } - Ok(Err(error)) => Err(error.into()), Err(_) => { let e = match task.await { Ok(r) => { @@ -215,7 +230,12 @@ where } 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 a305f89..016a1c8 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -866,4 +866,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); + } } From eb9a60531bfbd7089136097c94914c0e5d0ef257 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Wed, 9 Sep 2026 14:34:43 +0300 Subject: [PATCH 10/29] feat(metrics): consolidate app call metrics into unified `fastedge_calls_total` Replace `fastedge_call_count` and `fastedge_error_total_count` with a single `fastedge_calls_total` metric that tracks all app call outcomes under an `outcome` label. Add support for the new `SUSPENDED` app result. Update HTTP service to emit metrics for requests with `DISABLED`, `RATE_LIMITED`, and `SUSPENDED` statuses. Add unit test coverage to ensure correct metric allocation across outcomes. --- crates/http-service/src/lib.rs | 3 +++ crates/runtime/src/lib.rs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index a305f89..3bdb4bd 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -311,6 +311,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, @@ -319,6 +320,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, @@ -327,6 +329,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, diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index b85a515..aa01745 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -68,6 +68,8 @@ pub enum AppResult { RATE_LIMITED, /// Request rejected because the app is disabled or in draft. DISABLED, + /// Request rejected because the app is suspended. + SUSPENDED, } pub type InstancePre = wasmtime::component::InstancePre>; From aed4ecc4a7259495fcbb35563067e4b335e64b1f Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Wed, 9 Sep 2026 15:39:48 +0300 Subject: [PATCH 11/29] fix(wasi-http): improve error handling for response tasks and logging --- crates/http-service/src/executor/wasi_http.rs | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/crates/http-service/src/executor/wasi_http.rs b/crates/http-service/src/executor/wasi_http.rs index daab131..7b5960a 100644 --- a/crates/http-service/src/executor/wasi_http.rs +++ b/crates/http-service/src/executor/wasi_http.rs @@ -202,32 +202,42 @@ where ); match receiver.await { - Ok(inner) => { - // The guest already invoked `response-outparam::set`, so a later - // failure of the task (e.g. an epoch interrupt while streaming the - // body) is never propagated to the caller. Log it here - this is - // the only place it can be reported. + 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 { - if let Ok(Err(e)) = task.await { - tracing::warn!(cause=?e, "incoming handler"); + 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(), ); - match inner { - Ok(response) => { - stats.status_code(response.status().as_u16()); - Ok(response) - } - Err(error) => Err(error.into()), - } + 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(), }; // Attach context instead of formatting `e` into a new message so the From e46db5a256171e7731523cf6a33655a990c43b60 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Mon, 14 Sep 2026 15:33:59 +0300 Subject: [PATCH 12/29] feat(proxywasm): add cache support with new host functions and cache backend integration --- crates/cache/src/lib.rs | 60 ++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 9 deletions(-) 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 } } From d80dd4f3590edd20b2af47faedc1806e4b1780d1 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Tue, 15 Sep 2026 12:04:34 +0300 Subject: [PATCH 13/29] feat(metrics): add value cache metrics and improve lifecycle management - Added `fastedge_kv_cache_bytes`, `_entries`, and `_max_bytes` metrics for tracking in-process key-value store cache occupancy. - Implemented lifecycle management to dynamically remove metrics for idle/disconnected stores. - Updated configuration to accurately define default cache sizes in bytes and ensured compatibility with Prometheus scraping. - Enhanced test coverage for cache behavior, including capacity, eviction, oversized values, and label sanitization. --- crates/runtime/src/app.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index ee5acde..3dc25a6 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 { + /// 100 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); } From 6d86fa6f8cd1bd772e1f6f7ed4a02433722d37e0 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Tue, 15 Sep 2026 15:53:17 +0300 Subject: [PATCH 14/29] feat(kv-cache): enhance cache identity management with distinct store_id and metric_label --- crates/runtime/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index 3dc25a6..e25e4f4 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -55,7 +55,7 @@ pub struct KvStoreOption { } impl KvStoreOption { - /// 100 MiB. Was 25 000 *entries* until 2026-09-15; the unit changed with + /// 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 { From 1417f189bee42f0029ae9bac8753ace4a4ac85c6 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Wed, 16 Sep 2026 12:08:56 +0300 Subject: [PATCH 15/29] feat(kv-store): improve error handling and retry logic for Redis operations - Enhanced error classification for Redis read failures with clearer differentiation between transient and permanent errors. - Introduced `retry_backoff`, `is_guest_fault`, and `read_error` helpers to streamline retry logic. - Optimized retry mechanism with capped backoff for `WAIT_AND_RETRY`. - Improved guest-facing error messages for deterministic errors. - Updated unit tests to cover new error handling logic comprehensively. - Integrated `read_status_of` in ProxyWasm host to map errors to appropriate status codes. --- crates/key-value-store/src/redis_impl.rs | 379 +++++++++++++++++++++-- crates/runtime/src/app.rs | 2 +- 2 files changed, 352 insertions(+), 29 deletions(-) diff --git a/crates/key-value-store/src/redis_impl.rs b/crates/key-value-store/src/redis_impl.rs index 1136ee5..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; @@ -24,9 +24,89 @@ 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 << (attempt.saturating_sub(1) as u32); - (REDIS_READ_RETRY_BASE_DELAY * scale).min(REDIS_READ_RETRY_MAX_DELAY) + 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 @@ -35,6 +115,11 @@ fn redis_read_retry_delay(attempt: usize) -> Duration { /// `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, @@ -44,13 +129,20 @@ where loop { match op().await { Ok(value) => return Ok(value), - Err(error) if attempt < REDIS_READ_RETRIES => { + 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"); - tokio::time::sleep(redis_read_retry_delay(attempt)).await; + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } } - Err(error) => return Err(error), } } } @@ -120,8 +212,9 @@ impl Store for RedisStore { retry_read(crate::CMD_GET, || async { self.conn().get(key).await }) .await .map_err(|error| { - tracing::warn!(cause = ?error, key, "kv-store: redis get"); - Error::InternalError + read_error(error, |error| { + tracing::warn!(cause = ?error, key, "kv-store: redis get"); + }) }) } @@ -136,8 +229,9 @@ impl Store for RedisStore { }) .await .map_err(|error| { - tracing::warn!(cause = ?error, key, min, max, "kv-store: redis zrangebyscore"); - Error::InternalError + read_error(error, |error| { + tracing::warn!(cause = ?error, key, min, max, "kv-store: redis zrangebyscore"); + }) }) } @@ -147,23 +241,32 @@ impl Store for RedisStore { let mut it = loop { match conn.scan_match(pattern).await { Ok(it) => break it, - Err(error) if attempt < REDIS_READ_RETRIES => { + 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"); - tokio::time::sleep(redis_read_retry_delay(attempt)).await; - } - Err(error) => { - tracing::warn!(cause = ?error, pattern, "kv-store: redis scan_match"); - return Err(Error::InternalError); + 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) @@ -175,23 +278,32 @@ impl Store for RedisStore { let mut it: AsyncIter<(Value, f64)> = loop { match conn.zscan_match(key, pattern).await { Ok(it) => break it, - Err(error) if attempt < REDIS_READ_RETRIES => { + 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"); - tokio::time::sleep(redis_read_retry_delay(attempt)).await; - } - Err(error) => { - tracing::warn!(cause = ?error, key, pattern, "kv-store: redis zscan_match"); - return Err(Error::InternalError); + 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) @@ -207,8 +319,219 @@ impl Store for RedisStore { }) .await .map_err(|error| { - tracing::warn!(cause = ?error, key, item, "kv-store: redis bf_exists"); - Error::InternalError + read_error(error, |error| { + tracing::warn!(cause = ?error, key, item, "kv-store: redis bf_exists"); + }) + }) + } +} + +#[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/src/app.rs b/crates/runtime/src/app.rs index e25e4f4..2d6612a 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -63,7 +63,7 @@ impl KvStoreOption { } fn default_cache_ttl() -> u64 { - 60 + 300 } } From 2e559492c7867baf22f06bb1e78723e0f60ad42d Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Wed, 16 Sep 2026 12:20:55 +0300 Subject: [PATCH 16/29] feat(runtime): reduce default cache TTL to 60 seconds - Updated default cache TTL from 300 seconds to 60 seconds to enable quicker expiration. --- crates/runtime/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index 2d6612a..e25e4f4 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -63,7 +63,7 @@ impl KvStoreOption { } fn default_cache_ttl() -> u64 { - 300 + 60 } } From 924e823e56a92f8035db12529d7ea795187ad9ca Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 18 Sep 2026 11:40:32 +0300 Subject: [PATCH 17/29] feat(runtime): introduce epoch grace mechanism for improved timeout handling Add `epoch_grace` module to refine epoch timeout decisions by distinguishing kernel-parked threads from CPU-exhausted guests. Track grace outcomes with the new `fastedge_epoch_stall_grace_total` metric. Update store functionality to utilize CPU-aware deadlines, enhancing reliability under memory pressure. --- Cargo.lock | 5 + crates/runtime/Cargo.toml | 2 + crates/runtime/src/lib.rs | 7 ++ crates/runtime/src/store.rs | 161 +++++++++++++++++++++++++---- crates/runtime/src/util/metrics.rs | 21 ++++ 5 files changed, 176 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 005c0c8..0c91c24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2842,6 +2842,7 @@ dependencies = [ "http", "http-backend", "key-value-store", + "libc", "moka", "secret", "serde", @@ -5059,3 +5060,7 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "fastedge_proxywasm" +version = "0.5.0" diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 1a7f9a9..a902c3e 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -32,6 +32,8 @@ bytes = "1.11" serde = "1.0" serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } +# Thread CPU clock (`CLOCK_THREAD_CPUTIME_ID`) for the epoch stall grace. +libc = "0.2" [dev-dependencies] claims = "0.8" diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index aa01745..9d98dfa 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,4 +1,5 @@ use crate::app::KvStoreOption; +use crate::epoch_grace::EpochGrace; use crate::store::HasStats; use http_backend::stats::ExtStatsTimer; use std::net::Ipv4Addr; @@ -23,6 +24,7 @@ use wasmtime::{ use wit_component::ComponentEncoder; pub mod app; +pub mod epoch_grace; pub mod instances; mod limiter; pub mod logger; @@ -116,6 +118,11 @@ pub struct Data { pub epoch_pause_ms: Arc, /// Whether elapsed time of external HTTP should refund epoch ticks. pub pause_epoch_timeout_for_external_http: bool, + /// CPU-aware grace for the epoch deadline: when the wall budget runs out + /// with no host-call credit, distinguishes a thread that was parked in the + /// kernel from a guest that really spent its budget. See + /// [`crate::epoch_grace`]. + pub epoch_grace: EpochGrace, /// 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, diff --git a/crates/runtime/src/store.rs b/crates/runtime/src/store.rs index 3bc9932..e32a425 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -1,3 +1,4 @@ +use crate::epoch_grace::{EpochGrace, EpochGraceEvent}; use crate::limiter::ProxyLimiter; use crate::logger::Logger; use crate::registry::CachedGraphRegistry; @@ -71,6 +72,13 @@ impl Store { pub fn is_oom(&self) -> bool { self.inner.data().store_limits.oom } + + /// (Re-)arm the epoch deadline to `ticks` from now, resetting the stall + /// grace baseline to the current thread. Use this instead of + /// `set_epoch_deadline` directly; see [`crate::epoch_grace`]. + pub fn arm_epoch_deadline(&mut self, ticks: u64) { + arm_epoch_deadline(&mut self.inner, ticks); + } } /// Error indicating a wasm operation failed because it required more memory @@ -376,25 +384,70 @@ impl StoreBuilder { cache: cache_impl, epoch_pause_ms: epoch_pause_ms.clone(), pause_epoch_timeout_for_external_http: self.epoch_exclude_http_wait, + epoch_grace: EpochGrace::new(DEFAULT_EPOCH_TICK_INTERVAL), _live_instance: crate::instances::LiveInstanceGuard::new(), }, ); inner.limiter(|state| &mut state.store_limits); // allow max number of epoch ticks (1 tick = 10 ms) - inner.set_epoch_deadline(self.max_duration); - // When the deadline fires, consume any host-call credit accumulated by - // `epoch_pause_ms` to extend it; if there is no credit, trap as before. - inner.epoch_deadline_callback(move |_ctx| { + arm_epoch_deadline(&mut inner, self.max_duration); + // When the deadline fires: first consume any host-call credit + // accumulated by `epoch_pause_ms` to extend it; failing that, extend + // once more if the executing thread was parked rather than running + // (see `epoch_grace`); otherwise trap as before. + inner.epoch_deadline_callback(move |mut 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)), - Some(ticks) => Ok(UpdateDeadline::Continue(ticks)), - } + epoch_deadline_decision(credit_ms, &mut ctx.data_mut().epoch_grace) }); Ok(Store { inner }) } } +/// Arm the epoch deadline to `ticks` and record the baseline the stall grace +/// measures against. Every place that sets a deadline must go through here, +/// or the grace has no baseline and denies. +pub(crate) fn arm_epoch_deadline(store: &mut wasmtime::Store>, ticks: u64) { + store.data_mut().epoch_grace.arm(ticks); + store.set_epoch_deadline(ticks); +} + +/// The epoch-deadline callback's decision, factored out so tests exercise the +/// production logic rather than a copy of it. +/// +/// Order matters: host-call credit is the *earned* extension (the guest was +/// waiting on external I/O the host chose not to charge) and takes precedence; +/// the stall grace is only consulted when there is none. +pub(crate) fn epoch_deadline_decision( + credit_ms: u64, + grace: &mut EpochGrace, +) -> Result { + if let Some(ticks) = epoch_credit_ticks(credit_ms) { + return Ok(UpdateDeadline::Continue(ticks)); + } + let event = grace.on_deadline(); + crate::util::metrics::report_epoch_grace(event); + match event { + EpochGraceEvent::Granted { + ticks, + cpu_used_ms, + wall_ms, + } => { + debug!( + ticks, + cpu_used_ms, + wall_ms, + grants = grace.grants(), + "epoch deadline reached while parked; extending" + ); + Ok(UpdateDeadline::Continue(ticks)) + } + EpochGraceEvent::Denied(reason) => { + debug!(?reason, "epoch deadline reached; trapping"); + Err(anyhow::Error::new(wasmtime::Trap::Interrupt)) + } + } +} + /// Compute how many epoch ticks to refund given accumulated host-call /// credit (in ms). /// @@ -492,26 +545,39 @@ mod tests { Engine::new(&cfg).unwrap() } - /// Install the same epoch-deadline callback used in production - /// (see `StoreBuilder::build_with_wasi`). - fn install_epoch_callback(store: &mut WtStore, epoch_pause_ms: Arc) { - store.epoch_deadline_callback(move |_ctx| { + /// Install the epoch-deadline callback used in production (see + /// `StoreBuilder::build_with_wasi`) — the same `epoch_deadline_decision`, + /// with the store data standing in for `Data::epoch_grace`. + fn install_epoch_callback(store: &mut WtStore, epoch_pause_ms: Arc) { + store.epoch_deadline_callback(move |mut ctx| { let credit_ms = epoch_pause_ms.swap(0, Ordering::Relaxed); - match epoch_credit_ticks(credit_ms) { - None => Err(anyhow::Error::new(Trap::Interrupt)), - Some(ticks) => Ok(UpdateDeadline::Continue(ticks)), - } + epoch_deadline_decision(credit_ms, ctx.data_mut()) }); } + /// Like `SPIN_WAT` but effectively unbounded (seconds of CPU), for tests + /// that must observe a trap: with the stall grace in place a 1-tick budget + /// can legitimately be extended a bounded number of times before the trap, + /// and the guest must still be running when that happens. + const LONG_SPIN_WAT: &str = r#" + (module + (global $i (mut i32) (i32.const 2000000000)) + (func (export "spin") + (loop $l + (global.set $i (i32.sub (global.get $i) (i32.const 1))) + (br_if $l (i32.gt_s (global.get $i) (i32.const 0)))))) + "#; + #[test] fn busy_loop_without_credit_traps_with_interrupt() { let engine = make_engine(); - let module = Module::new(&engine, SPIN_WAT).unwrap(); + let module = Module::new(&engine, LONG_SPIN_WAT).unwrap(); let epoch_pause_ms = Arc::new(AtomicU64::new(0)); - let mut store = WtStore::new(&engine, ()); + // 1 ms per tick, matching the test ticker below. + let mut store = WtStore::new(&engine, EpochGrace::new(1)); store.set_epoch_deadline(1); // very short budget: 1 tick + store.data_mut().arm(1); install_epoch_callback(&mut store, epoch_pause_ms.clone()); // Background ticker: bump the engine epoch until the test signals stop. @@ -538,7 +604,9 @@ mod tests { ticker.join().unwrap(); // The guest's busy loop must be cut short by the epoch deadline - // because `epoch_pause_ms` was never incremented. + // because `epoch_pause_ms` was never incremented — and a *busy* guest + // consumes CPU at wall rate, so the stall grace cannot rescue it more + // than its bounded number of times either. let err = result.expect_err("guest must trap when no host-call credit is deposited"); let trap = err.root_cause().downcast_ref::().copied(); assert_eq!( @@ -557,8 +625,10 @@ mod tests { let module = Module::new(&engine, SPIN_WAT).unwrap(); let epoch_pause_ms = Arc::new(AtomicU64::new(0)); - let mut store = WtStore::new(&engine, ()); + // 1 ms per tick, matching the test ticker below. + let mut store = WtStore::new(&engine, EpochGrace::new(1)); store.set_epoch_deadline(1); // very short budget: 1 tick + store.data_mut().arm(1); install_epoch_callback(&mut store, epoch_pause_ms.clone()); // Background ticker bumps the epoch AND deposits generous credit so @@ -598,6 +668,57 @@ mod tests { result.expect("guest must complete when epoch credit is deposited"); } + /// A worker that is parked while the wall budget runs out — no CPU + /// consumed — must be granted the unused budget instead of trapping: the + /// guest did no work, so it has not used anything. `thread::sleep` is a + /// faithful model of a kernel park: wall time passes, thread CPU time does + /// not. + #[test] + fn parked_thread_is_granted_grace_and_completes() { + let engine = make_engine(); + let module = Module::new(&engine, SPIN_WAT).unwrap(); + let epoch_pause_ms = Arc::new(AtomicU64::new(0)); + + // 200 ticks × 1 ms = 200 ms of budget: generous next to the few ms the + // bounded spin needs, so a slow CI box cannot turn the grant into a + // second, legitimate deadline hit. + const BUDGET_TICKS: u64 = 200; + let mut store = WtStore::new(&engine, EpochGrace::new(1)); + store.set_epoch_deadline(BUDGET_TICKS); + store.data_mut().arm(BUDGET_TICKS); + install_epoch_callback(&mut store, epoch_pause_ms.clone()); + + let stop = Arc::new(AtomicBool::new(false)); + let ticker = { + let engine = engine.clone(); + let stop = stop.clone(); + thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + engine.increment_epoch(); + thread::sleep(Duration::from_millis(1)); + } + }) + }; + + let instance = Instance::new(&mut store, &module, &[]).unwrap(); + let spin = instance + .get_typed_func::<(), ()>(&mut store, "spin") + .unwrap(); + + // Park: the ticker blows through the entire budget while this thread + // consumes no CPU at all. + thread::sleep(Duration::from_millis(300)); + + let result = spin.call(&mut store, ()); + + stop.store(true, Ordering::Relaxed); + ticker.join().unwrap(); + + result.expect("a parked guest must be granted its unused budget, not trapped"); + assert_eq!(store.data().grants(), 1, "exactly one grace grant expected"); + assert_eq!(epoch_pause_ms.load(Ordering::Relaxed), 0); + } + // ── live-instance accounting ────────────────────────────────────────── /// No-op stats sink; `StoreBuilder::build` only needs `HasStats` to wire the diff --git a/crates/runtime/src/util/metrics.rs b/crates/runtime/src/util/metrics.rs index 25b994e..c8b089d 100644 --- a/crates/runtime/src/util/metrics.rs +++ b/crates/runtime/src/util/metrics.rs @@ -8,6 +8,7 @@ use std::sync::OnceLock; use crate::AppResult; +use crate::epoch_grace::EpochGraceEvent; /// Sink invoked for every app call: outcome, executor label(s), duration in /// microseconds and WASM linear memory used in bytes. @@ -27,3 +28,23 @@ pub fn metrics(result: AppResult, label: &[&str], duration: Option, memory_ sink(result, label, duration, memory_used); } } + +// ── epoch stall grace ──────────────────────────────────────────────────────── + +/// Sink for epoch-deadline decisions taken when the wall budget ran out with +/// no host-call credit (see [`crate::epoch_grace`]). +pub type EpochGraceSink = fn(event: EpochGraceEvent); + +static EPOCH_GRACE_SINK: OnceLock = OnceLock::new(); + +/// Install the process-global epoch-grace sink. Subsequent calls are ignored. +pub fn set_epoch_grace_sink(sink: EpochGraceSink) { + let _ = EPOCH_GRACE_SINK.set(sink); +} + +/// Report one epoch-grace decision (no-op when no sink is installed). +pub fn report_epoch_grace(event: EpochGraceEvent) { + if let Some(sink) = EPOCH_GRACE_SINK.get() { + sink(event); + } +} From 79dda4e87cdaf9c860177c59cc535067f34b1d26 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 18 Sep 2026 13:52:01 +0300 Subject: [PATCH 18/29] feat(runtime): refine app-call error classification with init-phase support Enhance error classification during app initialization (`_initialize`, `on_context_create`) to distinguish between initialization-phase timeouts and request-phase timeouts. Introduce new `CallPhase` enum to provide phase-specific context for metrics. Update metrics labels to differentiate `init_timeout` from standard `timeout`. --- crates/runtime/src/epoch_grace.rs | 431 +++++++++++++++++++++++++++++ crates/runtime/src/lib.rs | 6 + crates/runtime/src/util/metrics.rs | 44 ++- 3 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 crates/runtime/src/epoch_grace.rs diff --git a/crates/runtime/src/epoch_grace.rs b/crates/runtime/src/epoch_grace.rs new file mode 100644 index 0000000..053be5e --- /dev/null +++ b/crates/runtime/src/epoch_grace.rs @@ -0,0 +1,431 @@ +//! CPU-aware grace for the wasm epoch deadline. +//! +//! The epoch budget is wall-clock: a ticker thread bumps the engine epoch every +//! `DEFAULT_EPOCH_TICK_INTERVAL` ms whether or not the guest's worker is +//! running. A worker parked in the kernel — most often in direct reclaim on a +//! first-touch page fault, which under memory pressure waits with no timeout — +//! therefore burns its budget while executing nothing, and traps with +//! `Trap::Interrupt` at the next epoch check. The guest did nothing wrong. +//! +//! This module lets the deadline callback tell that case apart from a guest +//! that really spent its budget. When the deadline fires with no host-call +//! credit left, compare the CPU time the executing thread consumed since the +//! budget was armed against the wall budget. If it used less than +//! `1 / STALL_CPU_DIVISOR` of it, the thread was parked: hand back the unused +//! part as wall time, at most [`MAX_STALL_GRANTS`] times per armed budget. +//! Otherwise trap exactly as before. +//! +//! Measurement is per thread (`CLOCK_THREAD_CPUTIME_ID`), so the comparison is +//! only meaningful while the guest keeps running on the thread the budget was +//! armed on. A fiber resumed on another worker after an awaited host call +//! fails that check and is denied — never granted on a garbage delta. That is +//! the conservative choice: a denial is today's behaviour, while a wrong grant +//! would let a runaway guest exceed its budget. + +use std::thread::{self, ThreadId}; +use std::time::Instant; + +/// Bounded number of extensions per armed budget. The worst-case wall time for +/// a guest that keeps getting parked is `budget × (1 + MAX_STALL_GRANTS)`. +pub const MAX_STALL_GRANTS: u32 = 3; + +/// A deadline hit counts as a stall when the CPU time consumed is below +/// `budget / STALL_CPU_DIVISOR`. Half is deliberately strict: a guest that +/// genuinely ran for most of its budget is not rescued by a little jitter. +pub const STALL_CPU_DIVISOR: u64 = 2; + +/// Why a deadline hit was not extended. Every variant is a trap, exactly as +/// before this module existed; the reason is exported so the "never granted" +/// cases stay visible instead of being folded into the timeout count. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DenyReason { + /// The deadline fired before any budget was armed. + NotArmed, + /// [`MAX_STALL_GRANTS`] extensions were already handed out for this budget. + GrantsExhausted, + /// The guest is running on a different thread than the one the budget was + /// armed on, so the per-thread CPU delta means nothing. + ThreadMigrated, + /// No thread CPU clock on this platform. + CpuClockUnavailable, + /// The guest really consumed its budget. + CpuExhausted { cpu_used_ms: u64, budget_ms: u64 }, +} + +impl DenyReason { + fn label(self) -> &'static str { + match self { + DenyReason::NotArmed => "denied_not_armed", + DenyReason::GrantsExhausted => "denied_grants_exhausted", + DenyReason::ThreadMigrated => "denied_thread_migrated", + DenyReason::CpuClockUnavailable => "denied_cpu_clock_unavailable", + DenyReason::CpuExhausted { .. } => "denied_cpu_exhausted", + } + } +} + +/// Outcome of one deadline hit that found no host-call credit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EpochGraceEvent { + /// The thread was parked; the deadline is extended by `ticks`. + Granted { + ticks: u64, + cpu_used_ms: u64, + wall_ms: u64, + }, + /// Trap, as before. + Denied(DenyReason), +} + +impl EpochGraceEvent { + /// Every value [`Self::outcome_label`] can return, for pre-initialising a + /// labelled counter so an outcome that never happened reads as `0`. + pub const OUTCOME_LABELS: &'static [&'static str] = &[ + "granted", + "denied_not_armed", + "denied_grants_exhausted", + "denied_thread_migrated", + "denied_cpu_clock_unavailable", + "denied_cpu_exhausted", + ]; + + /// Stable metric label for this outcome. + pub fn outcome_label(&self) -> &'static str { + match self { + EpochGraceEvent::Granted { .. } => "granted", + EpochGraceEvent::Denied(reason) => reason.label(), + } + } +} + +#[derive(Debug, Clone, Copy)] +struct Baseline { + thread: ThreadId, + /// Thread CPU time when the budget was armed (or last extended). + cpu_ns: Option, + /// Wall clock when the budget was armed (or last extended); for logging. + wall: Instant, + /// Ticks the current deadline was set to. + ticks: u64, +} + +/// Per-store state for the deadline callback. Lives in the store data, so the +/// callback reaches it through `StoreContextMut::data_mut` with no shared +/// state and no atomics. +#[derive(Debug)] +pub struct EpochGrace { + tick_ms: u64, + baseline: Option, + grants: u32, +} + +impl EpochGrace { + /// `tick_ms` is the epoch ticker period the budget is denominated in + /// (`DEFAULT_EPOCH_TICK_INTERVAL` in production). + pub fn new(tick_ms: u64) -> Self { + Self { + tick_ms, + baseline: None, + grants: 0, + } + } + + /// Record that a fresh budget of `ticks` was just armed on the current + /// thread. Call this together with `Store::set_epoch_deadline`; every arm + /// starts a new grant allowance. + pub fn arm(&mut self, ticks: u64) { + self.arm_at( + ticks, + thread::current().id(), + thread_cpu_ns(), + Instant::now(), + ); + } + + fn arm_at(&mut self, ticks: u64, thread: ThreadId, cpu_ns: Option, wall: Instant) { + self.baseline = Some(Baseline { + thread, + cpu_ns, + wall, + ticks, + }); + self.grants = 0; + } + + /// Extensions handed out for the currently armed budget. + pub fn grants(&self) -> u32 { + self.grants + } + + /// Decide what to do about a deadline hit that has no host-call credit, + /// measured on the current thread right now. + pub fn on_deadline(&mut self) -> EpochGraceEvent { + self.decide(thread_cpu_ns(), thread::current().id(), Instant::now()) + } + + /// The decision proper, with the clocks passed in so it can be tested + /// without sleeping or spinning. + pub fn decide( + &mut self, + now_cpu_ns: Option, + now_thread: ThreadId, + now: Instant, + ) -> EpochGraceEvent { + let Some(baseline) = self.baseline.as_mut() else { + return EpochGraceEvent::Denied(DenyReason::NotArmed); + }; + if self.grants >= MAX_STALL_GRANTS { + return EpochGraceEvent::Denied(DenyReason::GrantsExhausted); + } + if now_thread != baseline.thread { + return EpochGraceEvent::Denied(DenyReason::ThreadMigrated); + } + let (Some(now_cpu_ns), Some(armed_cpu_ns)) = (now_cpu_ns, baseline.cpu_ns) else { + return EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable); + }; + + let cpu_used_ms = now_cpu_ns.saturating_sub(armed_cpu_ns) / 1_000_000; + let budget_ms = baseline.ticks.saturating_mul(self.tick_ms); + if cpu_used_ms.saturating_mul(STALL_CPU_DIVISOR) >= budget_ms { + return EpochGraceEvent::Denied(DenyReason::CpuExhausted { + cpu_used_ms, + budget_ms, + }); + } + + // Parked. Hand back the part of the budget the guest never got to use, + // rounded up to whole ticks so it is never under-refunded, and start + // measuring the next segment from here. + let remaining_ms = budget_ms - cpu_used_ms; + let ticks = remaining_ms.div_ceil(self.tick_ms.max(1)).max(1); + let wall_ms = now.saturating_duration_since(baseline.wall).as_millis() as u64; + baseline.cpu_ns = Some(now_cpu_ns); + baseline.wall = now; + baseline.ticks = ticks; + self.grants += 1; + EpochGraceEvent::Granted { + ticks, + cpu_used_ms, + wall_ms, + } + } +} + +/// CPU time consumed by the calling thread, in nanoseconds. +/// +/// `CLOCK_THREAD_CPUTIME_ID` is what makes the whole scheme work: it advances +/// only while this thread is on a CPU, so a thread parked in the kernel reads +/// the same value before and after the park. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub fn thread_cpu_ns() -> Option { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `ts` is a valid, writable `timespec`, and the clock id is a + // constant supported on both targets this is compiled for. + let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + (rc == 0).then(|| (ts.tv_sec as u64).saturating_mul(1_000_000_000) + ts.tv_nsec as u64) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub fn thread_cpu_ns() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + const TICK_MS: u64 = 10; + const NS_PER_MS: u64 = 1_000_000; + + fn armed(ticks: u64, cpu_ms: u64) -> (EpochGrace, ThreadId, Instant) { + let tid = thread::current().id(); + let t0 = Instant::now(); + let mut g = EpochGrace::new(TICK_MS); + g.arm_at(ticks, tid, Some(cpu_ms * NS_PER_MS), t0); + (g, tid, t0) + } + + #[test] + fn deadline_before_any_arm_is_denied() { + let mut g = EpochGrace::new(TICK_MS); + assert_eq!( + g.decide(Some(0), thread::current().id(), Instant::now()), + EpochGraceEvent::Denied(DenyReason::NotArmed) + ); + } + + #[test] + fn parked_thread_is_granted_the_unused_budget() { + // 100 ticks × 10 ms = 1 s of wall budget; the thread used 50 ms of CPU. + let (mut g, tid, t0) = armed(100, 1_000); + let ev = g.decide( + Some((1_000 + 50) * NS_PER_MS), + tid, + t0 + Duration::from_millis(1_000), + ); + assert_eq!( + ev, + EpochGraceEvent::Granted { + ticks: 95, // (1000 ms - 50 ms) / 10 ms + cpu_used_ms: 50, + wall_ms: 1_000, + } + ); + assert_eq!(g.grants(), 1); + } + + #[test] + fn busy_thread_is_denied() { + let (mut g, tid, t0) = armed(100, 1_000); + // 600 ms of CPU out of a 1 s budget: it really ran. + let ev = g.decide(Some((1_000 + 600) * NS_PER_MS), tid, t0); + assert_eq!( + ev, + EpochGraceEvent::Denied(DenyReason::CpuExhausted { + cpu_used_ms: 600, + budget_ms: 1_000, + }) + ); + assert_eq!(g.grants(), 0); + } + + #[test] + fn exactly_half_the_budget_is_denied() { + // The boundary belongs to "busy": STALL_CPU_DIVISOR is a strict bound. + let (mut g, tid, t0) = armed(100, 0); + let ev = g.decide(Some(500 * NS_PER_MS), tid, t0); + assert!(matches!( + ev, + EpochGraceEvent::Denied(DenyReason::CpuExhausted { .. }) + )); + } + + #[test] + fn migrated_thread_is_denied_even_when_idle() { + let (mut g, _tid, t0) = armed(100, 1_000); + let other = thread::spawn(|| thread::current().id()).join().unwrap(); + // Zero CPU consumed would look like a park — but the delta is across + // two different threads' clocks, so it must not be trusted. + assert_eq!( + g.decide(Some(1_000 * NS_PER_MS), other, t0), + EpochGraceEvent::Denied(DenyReason::ThreadMigrated) + ); + } + + #[test] + fn grants_are_bounded_per_armed_budget() { + let (mut g, tid, t0) = armed(100, 1_000); + for i in 0..MAX_STALL_GRANTS { + let ev = g.decide(Some(1_000 * NS_PER_MS), tid, t0); + assert!( + matches!(ev, EpochGraceEvent::Granted { .. }), + "grant #{i} expected, got {ev:?}" + ); + } + assert_eq!( + g.decide(Some(1_000 * NS_PER_MS), tid, t0), + EpochGraceEvent::Denied(DenyReason::GrantsExhausted) + ); + } + + #[test] + fn re_arming_resets_the_grant_allowance() { + let (mut g, tid, t0) = armed(100, 1_000); + for _ in 0..MAX_STALL_GRANTS { + g.decide(Some(1_000 * NS_PER_MS), tid, t0); + } + g.arm_at(100, tid, Some(1_000 * NS_PER_MS), t0); + assert_eq!(g.grants(), 0); + assert!(matches!( + g.decide(Some(1_000 * NS_PER_MS), tid, t0), + EpochGraceEvent::Granted { .. } + )); + } + + #[test] + fn a_grant_measures_the_next_segment_from_the_grant() { + // After a grant, CPU consumed *before* it must not count against the + // new, smaller budget. + let (mut g, tid, t0) = armed(100, 1_000); + let ev = g.decide(Some((1_000 + 100) * NS_PER_MS), tid, t0); + let EpochGraceEvent::Granted { ticks, .. } = ev else { + panic!("{ev:?}") + }; + assert_eq!(ticks, 90); + // Another 100 ms of CPU against the new 900 ms budget: still parked. + let ev = g.decide(Some((1_000 + 200) * NS_PER_MS), tid, t0); + assert_eq!( + ev, + EpochGraceEvent::Granted { + ticks: 80, + cpu_used_ms: 100, + wall_ms: 0, + } + ); + } + + #[test] + fn missing_cpu_clock_is_denied() { + let tid = thread::current().id(); + let mut g = EpochGrace::new(TICK_MS); + g.arm_at(100, tid, None, Instant::now()); + assert_eq!( + g.decide(Some(0), tid, Instant::now()), + EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable) + ); + g.arm_at(100, tid, Some(0), Instant::now()); + assert_eq!( + g.decide(None, tid, Instant::now()), + EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable) + ); + } + + #[test] + fn every_outcome_has_a_pre_initialised_label() { + let events = [ + EpochGraceEvent::Granted { + ticks: 1, + cpu_used_ms: 0, + wall_ms: 0, + }, + EpochGraceEvent::Denied(DenyReason::NotArmed), + EpochGraceEvent::Denied(DenyReason::GrantsExhausted), + EpochGraceEvent::Denied(DenyReason::ThreadMigrated), + EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable), + EpochGraceEvent::Denied(DenyReason::CpuExhausted { + cpu_used_ms: 1, + budget_ms: 1, + }), + ]; + assert_eq!(events.len(), EpochGraceEvent::OUTCOME_LABELS.len()); + for ev in events { + assert!( + EpochGraceEvent::OUTCOME_LABELS.contains(&ev.outcome_label()), + "{ev:?} -> {}", + ev.outcome_label() + ); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn thread_cpu_clock_advances_with_work_and_not_with_sleep() { + let a = thread_cpu_ns().expect("thread CPU clock"); + thread::sleep(Duration::from_millis(20)); + let b = thread_cpu_ns().unwrap(); + // Sleeping costs (close to) nothing. + assert!(b - a < 5 * NS_PER_MS, "sleep charged {} ns of CPU", b - a); + // Burn ~2 ms of real CPU; `black_box` keeps the loop from being + // optimised away. + let mut x = 0u64; + while thread_cpu_ns().unwrap() - b < 2 * NS_PER_MS { + x = std::hint::black_box(x.wrapping_mul(6364136223846793005).wrapping_add(1)); + } + std::hint::black_box(x); + assert!(thread_cpu_ns().unwrap() - b >= 2 * NS_PER_MS); + } +} diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 9d98dfa..17fef6e 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -56,6 +56,12 @@ 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 { diff --git a/crates/runtime/src/util/metrics.rs b/crates/runtime/src/util/metrics.rs index c8b089d..4950d87 100644 --- a/crates/runtime/src/util/metrics.rs +++ b/crates/runtime/src/util/metrics.rs @@ -10,10 +10,30 @@ use std::sync::OnceLock; use crate::AppResult; use crate::epoch_grace::EpochGraceEvent; -/// Sink invoked for every app call: outcome, executor label(s), duration in -/// microseconds and WASM linear memory used in bytes. -pub type MetricsSink = - fn(result: AppResult, label: &[&str], duration: Option, memory_used: Option); +/// 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, +} + +/// 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, +); static SINK: OnceLock = OnceLock::new(); @@ -22,10 +42,22 @@ pub fn set_sink(sink: MetricsSink) { let _ = SINK.set(sink); } -/// Report one app call to the installed sink (no-op when none is installed). +/// 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, label, duration, memory_used); + sink(result, phase, label, duration, memory_used); } } From 5ab352e5d45ee681174bd8214e7445e697a7072c Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Mon, 28 Sep 2026 17:01:06 +0300 Subject: [PATCH 19/29] feat(runtime): add rebase mechanism for improved stall grace handling Implement `rebase` functionality to redefine baseline for credited deadline extensions, enhancing CPU usage accuracy while maintaining stall grant allowances. Introduce and refine supporting tests for the new mechanism. --- crates/runtime/src/epoch_grace.rs | 74 +++++++++++++++++++++++++++++++ crates/runtime/src/store.rs | 17 +++++++ 2 files changed, 91 insertions(+) diff --git a/crates/runtime/src/epoch_grace.rs b/crates/runtime/src/epoch_grace.rs index 053be5e..27b0f16 100644 --- a/crates/runtime/src/epoch_grace.rs +++ b/crates/runtime/src/epoch_grace.rs @@ -152,11 +152,46 @@ impl EpochGrace { self.grants = 0; } + /// Re-baseline for a *credited* deadline extension (host-call credit + /// returned `Continue(ticks)`): the new segment of `ticks` is measured + /// from the current thread, CPU clock and wall clock, so a later + /// no-credit hit compares CPU used within this segment against this + /// segment's budget — not the accumulated usage since the original arm + /// against a stale budget. + /// + /// Deliberately unlike [`Self::arm`], the grant counter is **not** + /// reset: stall grants stay bounded to [`MAX_STALL_GRANTS`] per armed + /// budget no matter how many credited extensions happen in between, so + /// host-call credit cannot be used to mint fresh stall allowances. + pub fn rebase(&mut self, ticks: u64) { + self.rebase_at( + ticks, + thread::current().id(), + thread_cpu_ns(), + Instant::now(), + ); + } + + fn rebase_at(&mut self, ticks: u64, thread: ThreadId, cpu_ns: Option, wall: Instant) { + self.baseline = Some(Baseline { + thread, + cpu_ns, + wall, + ticks, + }); + } + /// Extensions handed out for the currently armed budget. pub fn grants(&self) -> u32 { self.grants } + /// Ticks of the current baseline segment; test-only introspection. + #[cfg(test)] + pub(crate) fn baseline_ticks(&self) -> Option { + self.baseline.map(|b| b.ticks) + } + /// Decide what to do about a deadline hit that has no host-call credit, /// measured on the current thread right now. pub fn on_deadline(&mut self) -> EpochGraceEvent { @@ -332,6 +367,45 @@ mod tests { ); } + #[test] + fn rebase_measures_the_next_segment_from_the_rebase() { + // 1 s armed budget; the guest burnt 900 ms of CPU, then a credited + // extension rebased it to a fresh 50-tick (500 ms) segment. A later + // hit that used 100 ms *within the new segment* must be a grant — + // without the rebase it would read 1 000 ms against the stale 1 s + // budget and be denied as CpuExhausted. + let (mut g, tid, t0) = armed(100, 1_000); + let t1 = t0 + Duration::from_millis(2_000); + g.rebase_at(50, tid, Some((1_000 + 900) * NS_PER_MS), t1); + let ev = g.decide( + Some((1_000 + 900 + 100) * NS_PER_MS), + tid, + t1 + Duration::from_millis(500), + ); + assert_eq!( + ev, + EpochGraceEvent::Granted { + ticks: 40, // (500 ms - 100 ms) / 10 ms + cpu_used_ms: 100, + wall_ms: 500, + } + ); + } + + #[test] + fn rebase_does_not_reset_the_grant_allowance() { + let (mut g, tid, t0) = armed(100, 1_000); + for _ in 0..MAX_STALL_GRANTS { + g.decide(Some(1_000 * NS_PER_MS), tid, t0); + } + g.rebase_at(100, tid, Some(1_000 * NS_PER_MS), t0); + assert_eq!(g.grants(), MAX_STALL_GRANTS); + assert_eq!( + g.decide(Some(1_000 * NS_PER_MS), tid, t0), + EpochGraceEvent::Denied(DenyReason::GrantsExhausted) + ); + } + #[test] fn re_arming_resets_the_grant_allowance() { let (mut g, tid, t0) = armed(100, 1_000); diff --git a/crates/runtime/src/store.rs b/crates/runtime/src/store.rs index e32a425..81acd9e 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -422,6 +422,10 @@ pub(crate) fn epoch_deadline_decision( grace: &mut EpochGrace, ) -> Result { if let Some(ticks) = epoch_credit_ticks(credit_ms) { + // The credited extension starts a new deadline segment; rebase the + // stall grace on it so the next no-credit hit measures CPU against + // this segment, not the long-expired one. Grant allowance is kept. + grace.rebase(ticks); return Ok(UpdateDeadline::Continue(ticks)); } let event = grace.on_deadline(); @@ -517,6 +521,19 @@ mod tests { ); } + #[test] + fn credited_extension_rebases_the_stall_grace() { + // A credited Continue starts a new deadline segment; the grace + // baseline must move to it, or the next no-credit hit measures CPU + // across both segments against the stale budget. + let mut grace = EpochGrace::new(DEFAULT_EPOCH_TICK_INTERVAL); + grace.arm(100); + let decision = epoch_deadline_decision(25, &mut grace).unwrap(); + assert!(matches!(decision, UpdateDeadline::Continue(3))); + assert_eq!(grace.baseline_ticks(), Some(3)); + assert_eq!(grace.grants(), 0); + } + // ── integration test: end-to-end deadline extension ─────────────────── // // These tests verify the same wiring used in production From f16d69f751d0674abcef3077e309507c97eecb5d Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Tue, 29 Sep 2026 11:40:13 +0300 Subject: [PATCH 20/29] refactor(runtime): remove `epoch_grace` module and associated metrics Remove the `epoch_grace` module and its related `fastedge_epoch_stall_grace_total` metric. Eliminate CPU-aware grace handling and its integration in the runtime. Update tests, documentation, and metrics initialization accordingly. --- Cargo.lock | 5 - crates/runtime/Cargo.toml | 2 - crates/runtime/src/epoch_grace.rs | 505 ----------------------------- crates/runtime/src/lib.rs | 7 - crates/runtime/src/store.rs | 178 ++-------- crates/runtime/src/util/metrics.rs | 21 -- 6 files changed, 20 insertions(+), 698 deletions(-) delete mode 100644 crates/runtime/src/epoch_grace.rs diff --git a/Cargo.lock b/Cargo.lock index 0c91c24..005c0c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2842,7 +2842,6 @@ dependencies = [ "http", "http-backend", "key-value-store", - "libc", "moka", "secret", "serde", @@ -5060,7 +5059,3 @@ dependencies = [ "cc", "pkg-config", ] - -[[patch.unused]] -name = "fastedge_proxywasm" -version = "0.5.0" diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index a902c3e..1a7f9a9 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -32,8 +32,6 @@ bytes = "1.11" serde = "1.0" serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } -# Thread CPU clock (`CLOCK_THREAD_CPUTIME_ID`) for the epoch stall grace. -libc = "0.2" [dev-dependencies] claims = "0.8" diff --git a/crates/runtime/src/epoch_grace.rs b/crates/runtime/src/epoch_grace.rs deleted file mode 100644 index 27b0f16..0000000 --- a/crates/runtime/src/epoch_grace.rs +++ /dev/null @@ -1,505 +0,0 @@ -//! CPU-aware grace for the wasm epoch deadline. -//! -//! The epoch budget is wall-clock: a ticker thread bumps the engine epoch every -//! `DEFAULT_EPOCH_TICK_INTERVAL` ms whether or not the guest's worker is -//! running. A worker parked in the kernel — most often in direct reclaim on a -//! first-touch page fault, which under memory pressure waits with no timeout — -//! therefore burns its budget while executing nothing, and traps with -//! `Trap::Interrupt` at the next epoch check. The guest did nothing wrong. -//! -//! This module lets the deadline callback tell that case apart from a guest -//! that really spent its budget. When the deadline fires with no host-call -//! credit left, compare the CPU time the executing thread consumed since the -//! budget was armed against the wall budget. If it used less than -//! `1 / STALL_CPU_DIVISOR` of it, the thread was parked: hand back the unused -//! part as wall time, at most [`MAX_STALL_GRANTS`] times per armed budget. -//! Otherwise trap exactly as before. -//! -//! Measurement is per thread (`CLOCK_THREAD_CPUTIME_ID`), so the comparison is -//! only meaningful while the guest keeps running on the thread the budget was -//! armed on. A fiber resumed on another worker after an awaited host call -//! fails that check and is denied — never granted on a garbage delta. That is -//! the conservative choice: a denial is today's behaviour, while a wrong grant -//! would let a runaway guest exceed its budget. - -use std::thread::{self, ThreadId}; -use std::time::Instant; - -/// Bounded number of extensions per armed budget. The worst-case wall time for -/// a guest that keeps getting parked is `budget × (1 + MAX_STALL_GRANTS)`. -pub const MAX_STALL_GRANTS: u32 = 3; - -/// A deadline hit counts as a stall when the CPU time consumed is below -/// `budget / STALL_CPU_DIVISOR`. Half is deliberately strict: a guest that -/// genuinely ran for most of its budget is not rescued by a little jitter. -pub const STALL_CPU_DIVISOR: u64 = 2; - -/// Why a deadline hit was not extended. Every variant is a trap, exactly as -/// before this module existed; the reason is exported so the "never granted" -/// cases stay visible instead of being folded into the timeout count. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DenyReason { - /// The deadline fired before any budget was armed. - NotArmed, - /// [`MAX_STALL_GRANTS`] extensions were already handed out for this budget. - GrantsExhausted, - /// The guest is running on a different thread than the one the budget was - /// armed on, so the per-thread CPU delta means nothing. - ThreadMigrated, - /// No thread CPU clock on this platform. - CpuClockUnavailable, - /// The guest really consumed its budget. - CpuExhausted { cpu_used_ms: u64, budget_ms: u64 }, -} - -impl DenyReason { - fn label(self) -> &'static str { - match self { - DenyReason::NotArmed => "denied_not_armed", - DenyReason::GrantsExhausted => "denied_grants_exhausted", - DenyReason::ThreadMigrated => "denied_thread_migrated", - DenyReason::CpuClockUnavailable => "denied_cpu_clock_unavailable", - DenyReason::CpuExhausted { .. } => "denied_cpu_exhausted", - } - } -} - -/// Outcome of one deadline hit that found no host-call credit. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EpochGraceEvent { - /// The thread was parked; the deadline is extended by `ticks`. - Granted { - ticks: u64, - cpu_used_ms: u64, - wall_ms: u64, - }, - /// Trap, as before. - Denied(DenyReason), -} - -impl EpochGraceEvent { - /// Every value [`Self::outcome_label`] can return, for pre-initialising a - /// labelled counter so an outcome that never happened reads as `0`. - pub const OUTCOME_LABELS: &'static [&'static str] = &[ - "granted", - "denied_not_armed", - "denied_grants_exhausted", - "denied_thread_migrated", - "denied_cpu_clock_unavailable", - "denied_cpu_exhausted", - ]; - - /// Stable metric label for this outcome. - pub fn outcome_label(&self) -> &'static str { - match self { - EpochGraceEvent::Granted { .. } => "granted", - EpochGraceEvent::Denied(reason) => reason.label(), - } - } -} - -#[derive(Debug, Clone, Copy)] -struct Baseline { - thread: ThreadId, - /// Thread CPU time when the budget was armed (or last extended). - cpu_ns: Option, - /// Wall clock when the budget was armed (or last extended); for logging. - wall: Instant, - /// Ticks the current deadline was set to. - ticks: u64, -} - -/// Per-store state for the deadline callback. Lives in the store data, so the -/// callback reaches it through `StoreContextMut::data_mut` with no shared -/// state and no atomics. -#[derive(Debug)] -pub struct EpochGrace { - tick_ms: u64, - baseline: Option, - grants: u32, -} - -impl EpochGrace { - /// `tick_ms` is the epoch ticker period the budget is denominated in - /// (`DEFAULT_EPOCH_TICK_INTERVAL` in production). - pub fn new(tick_ms: u64) -> Self { - Self { - tick_ms, - baseline: None, - grants: 0, - } - } - - /// Record that a fresh budget of `ticks` was just armed on the current - /// thread. Call this together with `Store::set_epoch_deadline`; every arm - /// starts a new grant allowance. - pub fn arm(&mut self, ticks: u64) { - self.arm_at( - ticks, - thread::current().id(), - thread_cpu_ns(), - Instant::now(), - ); - } - - fn arm_at(&mut self, ticks: u64, thread: ThreadId, cpu_ns: Option, wall: Instant) { - self.baseline = Some(Baseline { - thread, - cpu_ns, - wall, - ticks, - }); - self.grants = 0; - } - - /// Re-baseline for a *credited* deadline extension (host-call credit - /// returned `Continue(ticks)`): the new segment of `ticks` is measured - /// from the current thread, CPU clock and wall clock, so a later - /// no-credit hit compares CPU used within this segment against this - /// segment's budget — not the accumulated usage since the original arm - /// against a stale budget. - /// - /// Deliberately unlike [`Self::arm`], the grant counter is **not** - /// reset: stall grants stay bounded to [`MAX_STALL_GRANTS`] per armed - /// budget no matter how many credited extensions happen in between, so - /// host-call credit cannot be used to mint fresh stall allowances. - pub fn rebase(&mut self, ticks: u64) { - self.rebase_at( - ticks, - thread::current().id(), - thread_cpu_ns(), - Instant::now(), - ); - } - - fn rebase_at(&mut self, ticks: u64, thread: ThreadId, cpu_ns: Option, wall: Instant) { - self.baseline = Some(Baseline { - thread, - cpu_ns, - wall, - ticks, - }); - } - - /// Extensions handed out for the currently armed budget. - pub fn grants(&self) -> u32 { - self.grants - } - - /// Ticks of the current baseline segment; test-only introspection. - #[cfg(test)] - pub(crate) fn baseline_ticks(&self) -> Option { - self.baseline.map(|b| b.ticks) - } - - /// Decide what to do about a deadline hit that has no host-call credit, - /// measured on the current thread right now. - pub fn on_deadline(&mut self) -> EpochGraceEvent { - self.decide(thread_cpu_ns(), thread::current().id(), Instant::now()) - } - - /// The decision proper, with the clocks passed in so it can be tested - /// without sleeping or spinning. - pub fn decide( - &mut self, - now_cpu_ns: Option, - now_thread: ThreadId, - now: Instant, - ) -> EpochGraceEvent { - let Some(baseline) = self.baseline.as_mut() else { - return EpochGraceEvent::Denied(DenyReason::NotArmed); - }; - if self.grants >= MAX_STALL_GRANTS { - return EpochGraceEvent::Denied(DenyReason::GrantsExhausted); - } - if now_thread != baseline.thread { - return EpochGraceEvent::Denied(DenyReason::ThreadMigrated); - } - let (Some(now_cpu_ns), Some(armed_cpu_ns)) = (now_cpu_ns, baseline.cpu_ns) else { - return EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable); - }; - - let cpu_used_ms = now_cpu_ns.saturating_sub(armed_cpu_ns) / 1_000_000; - let budget_ms = baseline.ticks.saturating_mul(self.tick_ms); - if cpu_used_ms.saturating_mul(STALL_CPU_DIVISOR) >= budget_ms { - return EpochGraceEvent::Denied(DenyReason::CpuExhausted { - cpu_used_ms, - budget_ms, - }); - } - - // Parked. Hand back the part of the budget the guest never got to use, - // rounded up to whole ticks so it is never under-refunded, and start - // measuring the next segment from here. - let remaining_ms = budget_ms - cpu_used_ms; - let ticks = remaining_ms.div_ceil(self.tick_ms.max(1)).max(1); - let wall_ms = now.saturating_duration_since(baseline.wall).as_millis() as u64; - baseline.cpu_ns = Some(now_cpu_ns); - baseline.wall = now; - baseline.ticks = ticks; - self.grants += 1; - EpochGraceEvent::Granted { - ticks, - cpu_used_ms, - wall_ms, - } - } -} - -/// CPU time consumed by the calling thread, in nanoseconds. -/// -/// `CLOCK_THREAD_CPUTIME_ID` is what makes the whole scheme work: it advances -/// only while this thread is on a CPU, so a thread parked in the kernel reads -/// the same value before and after the park. -#[cfg(any(target_os = "linux", target_os = "macos"))] -pub fn thread_cpu_ns() -> Option { - let mut ts = libc::timespec { - tv_sec: 0, - tv_nsec: 0, - }; - // SAFETY: `ts` is a valid, writable `timespec`, and the clock id is a - // constant supported on both targets this is compiled for. - let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) }; - (rc == 0).then(|| (ts.tv_sec as u64).saturating_mul(1_000_000_000) + ts.tv_nsec as u64) -} - -#[cfg(not(any(target_os = "linux", target_os = "macos")))] -pub fn thread_cpu_ns() -> Option { - None -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - const TICK_MS: u64 = 10; - const NS_PER_MS: u64 = 1_000_000; - - fn armed(ticks: u64, cpu_ms: u64) -> (EpochGrace, ThreadId, Instant) { - let tid = thread::current().id(); - let t0 = Instant::now(); - let mut g = EpochGrace::new(TICK_MS); - g.arm_at(ticks, tid, Some(cpu_ms * NS_PER_MS), t0); - (g, tid, t0) - } - - #[test] - fn deadline_before_any_arm_is_denied() { - let mut g = EpochGrace::new(TICK_MS); - assert_eq!( - g.decide(Some(0), thread::current().id(), Instant::now()), - EpochGraceEvent::Denied(DenyReason::NotArmed) - ); - } - - #[test] - fn parked_thread_is_granted_the_unused_budget() { - // 100 ticks × 10 ms = 1 s of wall budget; the thread used 50 ms of CPU. - let (mut g, tid, t0) = armed(100, 1_000); - let ev = g.decide( - Some((1_000 + 50) * NS_PER_MS), - tid, - t0 + Duration::from_millis(1_000), - ); - assert_eq!( - ev, - EpochGraceEvent::Granted { - ticks: 95, // (1000 ms - 50 ms) / 10 ms - cpu_used_ms: 50, - wall_ms: 1_000, - } - ); - assert_eq!(g.grants(), 1); - } - - #[test] - fn busy_thread_is_denied() { - let (mut g, tid, t0) = armed(100, 1_000); - // 600 ms of CPU out of a 1 s budget: it really ran. - let ev = g.decide(Some((1_000 + 600) * NS_PER_MS), tid, t0); - assert_eq!( - ev, - EpochGraceEvent::Denied(DenyReason::CpuExhausted { - cpu_used_ms: 600, - budget_ms: 1_000, - }) - ); - assert_eq!(g.grants(), 0); - } - - #[test] - fn exactly_half_the_budget_is_denied() { - // The boundary belongs to "busy": STALL_CPU_DIVISOR is a strict bound. - let (mut g, tid, t0) = armed(100, 0); - let ev = g.decide(Some(500 * NS_PER_MS), tid, t0); - assert!(matches!( - ev, - EpochGraceEvent::Denied(DenyReason::CpuExhausted { .. }) - )); - } - - #[test] - fn migrated_thread_is_denied_even_when_idle() { - let (mut g, _tid, t0) = armed(100, 1_000); - let other = thread::spawn(|| thread::current().id()).join().unwrap(); - // Zero CPU consumed would look like a park — but the delta is across - // two different threads' clocks, so it must not be trusted. - assert_eq!( - g.decide(Some(1_000 * NS_PER_MS), other, t0), - EpochGraceEvent::Denied(DenyReason::ThreadMigrated) - ); - } - - #[test] - fn grants_are_bounded_per_armed_budget() { - let (mut g, tid, t0) = armed(100, 1_000); - for i in 0..MAX_STALL_GRANTS { - let ev = g.decide(Some(1_000 * NS_PER_MS), tid, t0); - assert!( - matches!(ev, EpochGraceEvent::Granted { .. }), - "grant #{i} expected, got {ev:?}" - ); - } - assert_eq!( - g.decide(Some(1_000 * NS_PER_MS), tid, t0), - EpochGraceEvent::Denied(DenyReason::GrantsExhausted) - ); - } - - #[test] - fn rebase_measures_the_next_segment_from_the_rebase() { - // 1 s armed budget; the guest burnt 900 ms of CPU, then a credited - // extension rebased it to a fresh 50-tick (500 ms) segment. A later - // hit that used 100 ms *within the new segment* must be a grant — - // without the rebase it would read 1 000 ms against the stale 1 s - // budget and be denied as CpuExhausted. - let (mut g, tid, t0) = armed(100, 1_000); - let t1 = t0 + Duration::from_millis(2_000); - g.rebase_at(50, tid, Some((1_000 + 900) * NS_PER_MS), t1); - let ev = g.decide( - Some((1_000 + 900 + 100) * NS_PER_MS), - tid, - t1 + Duration::from_millis(500), - ); - assert_eq!( - ev, - EpochGraceEvent::Granted { - ticks: 40, // (500 ms - 100 ms) / 10 ms - cpu_used_ms: 100, - wall_ms: 500, - } - ); - } - - #[test] - fn rebase_does_not_reset_the_grant_allowance() { - let (mut g, tid, t0) = armed(100, 1_000); - for _ in 0..MAX_STALL_GRANTS { - g.decide(Some(1_000 * NS_PER_MS), tid, t0); - } - g.rebase_at(100, tid, Some(1_000 * NS_PER_MS), t0); - assert_eq!(g.grants(), MAX_STALL_GRANTS); - assert_eq!( - g.decide(Some(1_000 * NS_PER_MS), tid, t0), - EpochGraceEvent::Denied(DenyReason::GrantsExhausted) - ); - } - - #[test] - fn re_arming_resets_the_grant_allowance() { - let (mut g, tid, t0) = armed(100, 1_000); - for _ in 0..MAX_STALL_GRANTS { - g.decide(Some(1_000 * NS_PER_MS), tid, t0); - } - g.arm_at(100, tid, Some(1_000 * NS_PER_MS), t0); - assert_eq!(g.grants(), 0); - assert!(matches!( - g.decide(Some(1_000 * NS_PER_MS), tid, t0), - EpochGraceEvent::Granted { .. } - )); - } - - #[test] - fn a_grant_measures_the_next_segment_from_the_grant() { - // After a grant, CPU consumed *before* it must not count against the - // new, smaller budget. - let (mut g, tid, t0) = armed(100, 1_000); - let ev = g.decide(Some((1_000 + 100) * NS_PER_MS), tid, t0); - let EpochGraceEvent::Granted { ticks, .. } = ev else { - panic!("{ev:?}") - }; - assert_eq!(ticks, 90); - // Another 100 ms of CPU against the new 900 ms budget: still parked. - let ev = g.decide(Some((1_000 + 200) * NS_PER_MS), tid, t0); - assert_eq!( - ev, - EpochGraceEvent::Granted { - ticks: 80, - cpu_used_ms: 100, - wall_ms: 0, - } - ); - } - - #[test] - fn missing_cpu_clock_is_denied() { - let tid = thread::current().id(); - let mut g = EpochGrace::new(TICK_MS); - g.arm_at(100, tid, None, Instant::now()); - assert_eq!( - g.decide(Some(0), tid, Instant::now()), - EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable) - ); - g.arm_at(100, tid, Some(0), Instant::now()); - assert_eq!( - g.decide(None, tid, Instant::now()), - EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable) - ); - } - - #[test] - fn every_outcome_has_a_pre_initialised_label() { - let events = [ - EpochGraceEvent::Granted { - ticks: 1, - cpu_used_ms: 0, - wall_ms: 0, - }, - EpochGraceEvent::Denied(DenyReason::NotArmed), - EpochGraceEvent::Denied(DenyReason::GrantsExhausted), - EpochGraceEvent::Denied(DenyReason::ThreadMigrated), - EpochGraceEvent::Denied(DenyReason::CpuClockUnavailable), - EpochGraceEvent::Denied(DenyReason::CpuExhausted { - cpu_used_ms: 1, - budget_ms: 1, - }), - ]; - assert_eq!(events.len(), EpochGraceEvent::OUTCOME_LABELS.len()); - for ev in events { - assert!( - EpochGraceEvent::OUTCOME_LABELS.contains(&ev.outcome_label()), - "{ev:?} -> {}", - ev.outcome_label() - ); - } - } - - #[cfg(any(target_os = "linux", target_os = "macos"))] - #[test] - fn thread_cpu_clock_advances_with_work_and_not_with_sleep() { - let a = thread_cpu_ns().expect("thread CPU clock"); - thread::sleep(Duration::from_millis(20)); - let b = thread_cpu_ns().unwrap(); - // Sleeping costs (close to) nothing. - assert!(b - a < 5 * NS_PER_MS, "sleep charged {} ns of CPU", b - a); - // Burn ~2 ms of real CPU; `black_box` keeps the loop from being - // optimised away. - let mut x = 0u64; - while thread_cpu_ns().unwrap() - b < 2 * NS_PER_MS { - x = std::hint::black_box(x.wrapping_mul(6364136223846793005).wrapping_add(1)); - } - std::hint::black_box(x); - assert!(thread_cpu_ns().unwrap() - b >= 2 * NS_PER_MS); - } -} diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 17fef6e..0aff266 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,5 +1,4 @@ use crate::app::KvStoreOption; -use crate::epoch_grace::EpochGrace; use crate::store::HasStats; use http_backend::stats::ExtStatsTimer; use std::net::Ipv4Addr; @@ -24,7 +23,6 @@ use wasmtime::{ use wit_component::ComponentEncoder; pub mod app; -pub mod epoch_grace; pub mod instances; mod limiter; pub mod logger; @@ -124,11 +122,6 @@ pub struct Data { pub epoch_pause_ms: Arc, /// Whether elapsed time of external HTTP should refund epoch ticks. pub pause_epoch_timeout_for_external_http: bool, - /// CPU-aware grace for the epoch deadline: when the wall budget runs out - /// with no host-call credit, distinguishes a thread that was parked in the - /// kernel from a guest that really spent its budget. See - /// [`crate::epoch_grace`]. - pub epoch_grace: EpochGrace, /// 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, diff --git a/crates/runtime/src/store.rs b/crates/runtime/src/store.rs index 81acd9e..3bc9932 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -1,4 +1,3 @@ -use crate::epoch_grace::{EpochGrace, EpochGraceEvent}; use crate::limiter::ProxyLimiter; use crate::logger::Logger; use crate::registry::CachedGraphRegistry; @@ -72,13 +71,6 @@ impl Store { pub fn is_oom(&self) -> bool { self.inner.data().store_limits.oom } - - /// (Re-)arm the epoch deadline to `ticks` from now, resetting the stall - /// grace baseline to the current thread. Use this instead of - /// `set_epoch_deadline` directly; see [`crate::epoch_grace`]. - pub fn arm_epoch_deadline(&mut self, ticks: u64) { - arm_epoch_deadline(&mut self.inner, ticks); - } } /// Error indicating a wasm operation failed because it required more memory @@ -384,74 +376,25 @@ impl StoreBuilder { cache: cache_impl, epoch_pause_ms: epoch_pause_ms.clone(), pause_epoch_timeout_for_external_http: self.epoch_exclude_http_wait, - epoch_grace: EpochGrace::new(DEFAULT_EPOCH_TICK_INTERVAL), _live_instance: crate::instances::LiveInstanceGuard::new(), }, ); inner.limiter(|state| &mut state.store_limits); // allow max number of epoch ticks (1 tick = 10 ms) - arm_epoch_deadline(&mut inner, self.max_duration); - // When the deadline fires: first consume any host-call credit - // accumulated by `epoch_pause_ms` to extend it; failing that, extend - // once more if the executing thread was parked rather than running - // (see `epoch_grace`); otherwise trap as before. - inner.epoch_deadline_callback(move |mut ctx| { + inner.set_epoch_deadline(self.max_duration); + // When the deadline fires, consume any host-call credit accumulated by + // `epoch_pause_ms` to extend it; if there is no credit, trap as before. + inner.epoch_deadline_callback(move |_ctx| { let credit_ms = epoch_pause_ms.swap(0, Ordering::Relaxed); - epoch_deadline_decision(credit_ms, &mut ctx.data_mut().epoch_grace) + match epoch_credit_ticks(credit_ms) { + None => Err(anyhow::Error::new(wasmtime::Trap::Interrupt)), + Some(ticks) => Ok(UpdateDeadline::Continue(ticks)), + } }); Ok(Store { inner }) } } -/// Arm the epoch deadline to `ticks` and record the baseline the stall grace -/// measures against. Every place that sets a deadline must go through here, -/// or the grace has no baseline and denies. -pub(crate) fn arm_epoch_deadline(store: &mut wasmtime::Store>, ticks: u64) { - store.data_mut().epoch_grace.arm(ticks); - store.set_epoch_deadline(ticks); -} - -/// The epoch-deadline callback's decision, factored out so tests exercise the -/// production logic rather than a copy of it. -/// -/// Order matters: host-call credit is the *earned* extension (the guest was -/// waiting on external I/O the host chose not to charge) and takes precedence; -/// the stall grace is only consulted when there is none. -pub(crate) fn epoch_deadline_decision( - credit_ms: u64, - grace: &mut EpochGrace, -) -> Result { - if let Some(ticks) = epoch_credit_ticks(credit_ms) { - // The credited extension starts a new deadline segment; rebase the - // stall grace on it so the next no-credit hit measures CPU against - // this segment, not the long-expired one. Grant allowance is kept. - grace.rebase(ticks); - return Ok(UpdateDeadline::Continue(ticks)); - } - let event = grace.on_deadline(); - crate::util::metrics::report_epoch_grace(event); - match event { - EpochGraceEvent::Granted { - ticks, - cpu_used_ms, - wall_ms, - } => { - debug!( - ticks, - cpu_used_ms, - wall_ms, - grants = grace.grants(), - "epoch deadline reached while parked; extending" - ); - Ok(UpdateDeadline::Continue(ticks)) - } - EpochGraceEvent::Denied(reason) => { - debug!(?reason, "epoch deadline reached; trapping"); - Err(anyhow::Error::new(wasmtime::Trap::Interrupt)) - } - } -} - /// Compute how many epoch ticks to refund given accumulated host-call /// credit (in ms). /// @@ -521,19 +464,6 @@ mod tests { ); } - #[test] - fn credited_extension_rebases_the_stall_grace() { - // A credited Continue starts a new deadline segment; the grace - // baseline must move to it, or the next no-credit hit measures CPU - // across both segments against the stale budget. - let mut grace = EpochGrace::new(DEFAULT_EPOCH_TICK_INTERVAL); - grace.arm(100); - let decision = epoch_deadline_decision(25, &mut grace).unwrap(); - assert!(matches!(decision, UpdateDeadline::Continue(3))); - assert_eq!(grace.baseline_ticks(), Some(3)); - assert_eq!(grace.grants(), 0); - } - // ── integration test: end-to-end deadline extension ─────────────────── // // These tests verify the same wiring used in production @@ -562,39 +492,26 @@ mod tests { Engine::new(&cfg).unwrap() } - /// Install the epoch-deadline callback used in production (see - /// `StoreBuilder::build_with_wasi`) — the same `epoch_deadline_decision`, - /// with the store data standing in for `Data::epoch_grace`. - fn install_epoch_callback(store: &mut WtStore, epoch_pause_ms: Arc) { - store.epoch_deadline_callback(move |mut ctx| { + /// Install the same epoch-deadline callback used in production + /// (see `StoreBuilder::build_with_wasi`). + fn install_epoch_callback(store: &mut WtStore, epoch_pause_ms: Arc) { + store.epoch_deadline_callback(move |_ctx| { let credit_ms = epoch_pause_ms.swap(0, Ordering::Relaxed); - epoch_deadline_decision(credit_ms, ctx.data_mut()) + match epoch_credit_ticks(credit_ms) { + None => Err(anyhow::Error::new(Trap::Interrupt)), + Some(ticks) => Ok(UpdateDeadline::Continue(ticks)), + } }); } - /// Like `SPIN_WAT` but effectively unbounded (seconds of CPU), for tests - /// that must observe a trap: with the stall grace in place a 1-tick budget - /// can legitimately be extended a bounded number of times before the trap, - /// and the guest must still be running when that happens. - const LONG_SPIN_WAT: &str = r#" - (module - (global $i (mut i32) (i32.const 2000000000)) - (func (export "spin") - (loop $l - (global.set $i (i32.sub (global.get $i) (i32.const 1))) - (br_if $l (i32.gt_s (global.get $i) (i32.const 0)))))) - "#; - #[test] fn busy_loop_without_credit_traps_with_interrupt() { let engine = make_engine(); - let module = Module::new(&engine, LONG_SPIN_WAT).unwrap(); + let module = Module::new(&engine, SPIN_WAT).unwrap(); let epoch_pause_ms = Arc::new(AtomicU64::new(0)); - // 1 ms per tick, matching the test ticker below. - let mut store = WtStore::new(&engine, EpochGrace::new(1)); + let mut store = WtStore::new(&engine, ()); store.set_epoch_deadline(1); // very short budget: 1 tick - store.data_mut().arm(1); install_epoch_callback(&mut store, epoch_pause_ms.clone()); // Background ticker: bump the engine epoch until the test signals stop. @@ -621,9 +538,7 @@ mod tests { ticker.join().unwrap(); // The guest's busy loop must be cut short by the epoch deadline - // because `epoch_pause_ms` was never incremented — and a *busy* guest - // consumes CPU at wall rate, so the stall grace cannot rescue it more - // than its bounded number of times either. + // because `epoch_pause_ms` was never incremented. let err = result.expect_err("guest must trap when no host-call credit is deposited"); let trap = err.root_cause().downcast_ref::().copied(); assert_eq!( @@ -642,10 +557,8 @@ mod tests { let module = Module::new(&engine, SPIN_WAT).unwrap(); let epoch_pause_ms = Arc::new(AtomicU64::new(0)); - // 1 ms per tick, matching the test ticker below. - let mut store = WtStore::new(&engine, EpochGrace::new(1)); + let mut store = WtStore::new(&engine, ()); store.set_epoch_deadline(1); // very short budget: 1 tick - store.data_mut().arm(1); install_epoch_callback(&mut store, epoch_pause_ms.clone()); // Background ticker bumps the epoch AND deposits generous credit so @@ -685,57 +598,6 @@ mod tests { result.expect("guest must complete when epoch credit is deposited"); } - /// A worker that is parked while the wall budget runs out — no CPU - /// consumed — must be granted the unused budget instead of trapping: the - /// guest did no work, so it has not used anything. `thread::sleep` is a - /// faithful model of a kernel park: wall time passes, thread CPU time does - /// not. - #[test] - fn parked_thread_is_granted_grace_and_completes() { - let engine = make_engine(); - let module = Module::new(&engine, SPIN_WAT).unwrap(); - let epoch_pause_ms = Arc::new(AtomicU64::new(0)); - - // 200 ticks × 1 ms = 200 ms of budget: generous next to the few ms the - // bounded spin needs, so a slow CI box cannot turn the grant into a - // second, legitimate deadline hit. - const BUDGET_TICKS: u64 = 200; - let mut store = WtStore::new(&engine, EpochGrace::new(1)); - store.set_epoch_deadline(BUDGET_TICKS); - store.data_mut().arm(BUDGET_TICKS); - install_epoch_callback(&mut store, epoch_pause_ms.clone()); - - let stop = Arc::new(AtomicBool::new(false)); - let ticker = { - let engine = engine.clone(); - let stop = stop.clone(); - thread::spawn(move || { - while !stop.load(Ordering::Relaxed) { - engine.increment_epoch(); - thread::sleep(Duration::from_millis(1)); - } - }) - }; - - let instance = Instance::new(&mut store, &module, &[]).unwrap(); - let spin = instance - .get_typed_func::<(), ()>(&mut store, "spin") - .unwrap(); - - // Park: the ticker blows through the entire budget while this thread - // consumes no CPU at all. - thread::sleep(Duration::from_millis(300)); - - let result = spin.call(&mut store, ()); - - stop.store(true, Ordering::Relaxed); - ticker.join().unwrap(); - - result.expect("a parked guest must be granted its unused budget, not trapped"); - assert_eq!(store.data().grants(), 1, "exactly one grace grant expected"); - assert_eq!(epoch_pause_ms.load(Ordering::Relaxed), 0); - } - // ── live-instance accounting ────────────────────────────────────────── /// No-op stats sink; `StoreBuilder::build` only needs `HasStats` to wire the diff --git a/crates/runtime/src/util/metrics.rs b/crates/runtime/src/util/metrics.rs index 4950d87..90baff4 100644 --- a/crates/runtime/src/util/metrics.rs +++ b/crates/runtime/src/util/metrics.rs @@ -8,7 +8,6 @@ use std::sync::OnceLock; use crate::AppResult; -use crate::epoch_grace::EpochGraceEvent; /// Where in an app call the outcome was decided. /// @@ -60,23 +59,3 @@ pub fn metrics_in_phase( sink(result, phase, label, duration, memory_used); } } - -// ── epoch stall grace ──────────────────────────────────────────────────────── - -/// Sink for epoch-deadline decisions taken when the wall budget ran out with -/// no host-call credit (see [`crate::epoch_grace`]). -pub type EpochGraceSink = fn(event: EpochGraceEvent); - -static EPOCH_GRACE_SINK: OnceLock = OnceLock::new(); - -/// Install the process-global epoch-grace sink. Subsequent calls are ignored. -pub fn set_epoch_grace_sink(sink: EpochGraceSink) { - let _ = EPOCH_GRACE_SINK.set(sink); -} - -/// Report one epoch-grace decision (no-op when no sink is installed). -pub fn report_epoch_grace(event: EpochGraceEvent) { - if let Some(sink) = EPOCH_GRACE_SINK.get() { - sink(event); - } -} From 20a2562ce66b37aa1dd9c93816cc11fa89ca535b Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Tue, 22 Sep 2026 13:11:50 +0300 Subject: [PATCH 21/29] feat(access-log): per-request access log shipped via log sender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the access-log work from feat/integration-with-log-sender onto current main (that branch is ~210 commits behind and carries the FastEdge-lib subtree at lib/ instead of fastedge-lib/). Emit exactly one record per processed request — for every outcome, including early rejections — describing how the edge server handled it: traceparent, final status, X-CDN-Internal-Status, app/client ids, method, uri and execution time. Transport is abstracted behind `runtime::access_log::AccessLogSender`; the server implements it as best-effort RFC 3164 syslog datagrams over UDP to the trace stream (default 127.0.0.1:9514, `--access-log-addr` / `--access-log-role`). Init failure falls back to the no-op sink. Covered paths: - ProxyWasm (v1/v2/v2a/v2b/v2c/v2d/v3), one record per lifecycle callback, tagged with the trigger and the initiating service. - Preview2 http-handler and standard wasi:http. - Outbound requests (`proxy_http_call` and the WASI/http-backend fetch client) as their own `fetch` records, correlated by traceparent via a dependency-free `ExtRequestLog` seam in http-backend. Requests without an inbound `traceparent` get a freshly generated W3C id (`runtime::trace::new_traceparent`) instead of a nanoid, and the resolved id is pinned onto the request so outbound records join the inbound one. `traceparent` is added to the default propagate-headers set. nanoid is dropped from proxywasm and http-service. Deviations from the source branch, for current main: - Initiator/version mapping extended to V2d (core-proxy) and V3 (nginx), matching the connection-gauge classification. - ProxyWasm record duration reads the per-call `Instant` already used by the metrics path (ungated from the `metrics` feature) rather than the stats row, whose `get_time_elapsed()` is a request-level accumulator and would over-report on streaming body callbacks. - The cdn-config/LMDB commit on that branch is a separate feature and is not included. --- Cargo.lock | 15 +- crates/http-backend/src/access_log.rs | 58 ++++++ crates/http-backend/src/lib.rs | 88 ++++++++- crates/http-service/Cargo.toml | 1 - crates/http-service/src/executor/http.rs | 7 + crates/http-service/src/executor/wasi_http.rs | 7 + crates/http-service/src/lib.rs | 82 +++++++-- crates/http-service/src/state.rs | 5 + crates/runtime/Cargo.toml | 1 + crates/runtime/src/access_log.rs | 170 ++++++++++++++++++ crates/runtime/src/lib.rs | 45 ++++- crates/runtime/src/registry.rs | 2 +- crates/runtime/src/trace.rs | 60 +++++++ src/context.rs | 2 +- 14 files changed, 515 insertions(+), 28 deletions(-) create mode 100644 crates/http-backend/src/access_log.rs create mode 100644 crates/runtime/src/access_log.rs create mode 100644 crates/runtime/src/trace.rs diff --git a/Cargo.lock b/Cargo.lock index 005c0c8..13ad3ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1595,7 +1595,6 @@ dependencies = [ "hyper", "hyper-util", "key-value-store", - "nanoid", "reactor", "runtime", "secret", @@ -2137,15 +2136,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" @@ -2843,6 +2833,7 @@ dependencies = [ "http-backend", "key-value-store", "moka", + "rand 0.8.7", "secret", "serde", "serde_json", @@ -5059,3 +5050,7 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "fastedge_proxywasm" +version = "0.5.0" 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 e5b4b8b..8b7ce2c 100644 --- a/crates/http-service/Cargo.toml +++ b/crates/http-service/Cargo.toml @@ -26,7 +26,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 d74b675..83a282a 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -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")?; diff --git a/crates/http-service/src/executor/wasi_http.rs b/crates/http-service/src/executor/wasi_http.rs index 7b5960a..bf489f8 100644 --- a/crates/http-service/src/executor/wasi_http.rs +++ b/crates/http-service/src/executor/wasi_http.rs @@ -94,6 +94,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); diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index da4f259..471d956 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -18,7 +18,10 @@ use hyper_util::{client::legacy::connect::Connect, rt::TokioIo}; 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; @@ -258,20 +261,67 @@ 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) => { @@ -341,6 +391,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 @@ -357,15 +411,15 @@ where } }; - let caller_ip = request - .headers() - .get(crate::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); + let caller_ip = request + .headers() + .get(crate::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); let response = match executor.execute(request, stats.clone()).await { Ok(mut response) => { @@ -404,6 +458,7 @@ where builder.body(msg)? } }; + log.duration = Some(Duration::from_micros(stats.get_time_elapsed())); Ok(response) } .instrument(span) @@ -537,8 +592,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. diff --git a/crates/http-service/src/state.rs b/crates/http-service/src/state.rs index ad62f6f..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 { diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 1a7f9a9..6fbfe1e 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -29,6 +29,7 @@ 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"] } 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/lib.rs b/crates/runtime/src/lib.rs index 0aff266..156b90c 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,5 +1,6 @@ use crate::app::KvStoreOption; use crate::store::HasStats; +use http_backend::access_log::ExtRequestLogHandle; use http_backend::stats::ExtStatsTimer; use std::net::Ipv4Addr; use std::sync::Arc; @@ -22,6 +23,7 @@ use wasmtime::{ }; use wit_component::ComponentEncoder; +pub mod access_log; pub mod app; pub mod instances; mod limiter; @@ -30,8 +32,12 @@ mod registry; pub mod service; pub mod store; pub mod stub; +pub mod trace; pub mod util; +pub use trace::new_traceparent; + +use crate::access_log::{AccessLogSender, NoopAccessLogSender}; use crate::app::SecretOption; use crate::logger::Logger; use crate::util::stats::StatsVisitor; @@ -129,6 +135,13 @@ 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 { @@ -162,6 +175,14 @@ impl WasiHttpView for Data { where Self: Sized, { + // 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 = self.inner.backend_request(head).map_err(|e| { tracing::warn!(cause=?e, "backend request"); @@ -180,8 +201,18 @@ impl WasiHttpView for Data { let resp = default_send_request_handler(request, OutgoingRequestConfig { use_tls, ..config }) .await; + let elapsed = started.elapsed(); if epoch_exclude_http_wait { - epoch_pause_ms.fetch_add(started.elapsed().as_millis() as u64, Ordering::Relaxed); + epoch_pause_ms.fetch_add(elapsed.as_millis() as u64, Ordering::Relaxed); + } + // 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 &resp { + Ok(incoming) => incoming.resp.status().as_u16(), + Err(_) => 0, + }; + handle.log(&log_method, &log_uri, status, elapsed); } Ok(resp) }); @@ -441,6 +472,18 @@ pub trait ContextT { caller_ip: Ipv4Addr, cfg: &App, ) -> Arc; + + /// 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/registry.rs b/crates/runtime/src/registry.rs index cb502fa..38c9c81 100644 --- a/crates/runtime/src/registry.rs +++ b/crates/runtime/src/registry.rs @@ -39,7 +39,7 @@ impl GraphRegistry for StoreRegistry { impl CachedGraphRegistry { pub fn new() -> 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)); 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/src/context.rs b/src/context.rs index ab93365..c06c9cb 100644 --- a/src/context.rs +++ b/src/context.rs @@ -46,7 +46,7 @@ impl PreCompiledLoader for Context { } fn load_module(&self, _id: u64) -> anyhow::Result { - unreachable!("") + unreachable!("fastedge-run loads components only, not core wasm modules") } } From 970efc1b0d2d627b54dcda8f4314d97a6a12233d Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Tue, 22 Sep 2026 11:19:24 +0300 Subject: [PATCH 22/29] feat(runtime): upgrade Wasmtime to 48.0.0 and refactor HTTP hooks - Upgraded to Wasmtime 48.0.0, adjusting dependencies and addressing breaking changes. - Refactored `WasiHttpHooks` implementation to align with Wasmtime 48's new `HttpHooks` design. - Streamlined epoch-related bookkeeping for HTTP requests and improved resource table handling. - Updated `Cargo.lock` with new versions and removed unused or outdated dependencies. --- CLAUDE.md | 2 +- Cargo.lock | 840 +++++++----------- Cargo.toml | 17 +- crates/http-service/Cargo.toml | 1 - crates/http-service/src/executor/http.rs | 42 +- crates/http-service/src/executor/mod.rs | 2 +- crates/http-service/src/executor/wasi_http.rs | 23 +- crates/http-service/src/lib.rs | 52 +- crates/runtime/Cargo.toml | 2 +- crates/runtime/src/lib.rs | 141 ++- crates/runtime/src/limiter.rs | 3 +- crates/runtime/src/logger.rs | 28 - crates/runtime/src/store.rs | 10 +- src/context.rs | 2 +- 14 files changed, 502 insertions(+), 663 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 417ee2c..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 diff --git a/Cargo.lock b/Cargo.lock index 13ad3ee..ea362f8 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#850554ec220d340359818f5c6dacef945b4def05" +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#850554ec220d340359818f5c6dacef945b4def05" 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#850554ec220d340359818f5c6dacef945b4def05" 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" @@ -1605,7 +1556,6 @@ dependencies = [ "tracing", "tracing-test", "utils", - "wasi-common", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", @@ -1835,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]] @@ -1849,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" @@ -2000,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" @@ -2035,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" @@ -2069,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]] @@ -2284,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", ] @@ -2423,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" @@ -2513,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]] @@ -2527,17 +2484,17 @@ dependencies = [ [[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", @@ -2591,35 +2548,25 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rand" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2634,21 +2581,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.3.4", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -2763,15 +2707,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", ] @@ -2831,6 +2776,7 @@ dependencies = [ "claims", "http", "http-backend", + "http-body-util", "key-value-store", "moka", "rand 0.8.7", @@ -2842,15 +2788,14 @@ dependencies = [ "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]] @@ -2865,19 +2810,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" @@ -2887,7 +2819,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -2898,16 +2830,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", @@ -2926,9 +2859,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", @@ -3089,11 +3022,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]] @@ -3109,7 +3042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3273,22 +3206,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" @@ -3310,7 +3227,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -3455,12 +3372,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", ] @@ -3493,23 +3409,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]] @@ -3521,20 +3440,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" @@ -3557,10 +3462,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" @@ -3590,7 +3495,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", @@ -3809,30 +3713,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" @@ -3888,13 +3768,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]] @@ -3930,16 +3817,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]] @@ -3961,37 +3847,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", @@ -4002,42 +3887,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", @@ -4046,62 +3933,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#850554ec220d340359818f5c6dacef945b4def05" 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", @@ -4109,22 +3995,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#850554ec220d340359818f5c6dacef945b4def05" +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", @@ -4135,137 +4039,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", @@ -4273,44 +4142,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#850554ec220d340359818f5c6dacef945b4def05" 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", @@ -4319,7 +4188,7 @@ dependencies = [ "tracing", "walkdir", "wasmtime", - "wiggle 36.0.12", + "wiggle", "windows", ] @@ -4354,15 +4223,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" @@ -4374,75 +4234,36 @@ dependencies = [ [[package]] name = "wiggle" -version = "36.0.12" +version = "48.0.2" 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" -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)", + "wasmtime-environ 48.0.2", + "wiggle-macro", ] [[package]] name = "wiggle-generate" -version = "36.0.12" +version = "48.0.2" dependencies = [ - "anyhow", - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", - "witx", -] - -[[package]] -name = "wiggle-generate" -version = "36.0.12" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-36.0.0#62166f65ac52a1f164419097a2a533ff50e4d07b" -dependencies = [ - "anyhow", "heck", "proc-macro2", "quote", "syn 2.0.119", + "wasmtime-environ 48.0.2", "witx", ] [[package]] name = "wiggle-macro" -version = "36.0.12" +version = "48.0.2" 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" -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]] @@ -4476,24 +4297,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" @@ -4766,9 +4569,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -4809,27 +4609,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]] @@ -4851,6 +4652,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" 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/http-service/Cargo.toml b/crates/http-service/Cargo.toml index 8b7ce2c..2456627 100644 --- a/crates/http-service/Cargo.toml +++ b/crates/http-service/Cargo.toml @@ -16,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 } diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index 83a282a..9f94ab2 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)] @@ -140,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 = @@ -166,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)?; @@ -185,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) } @@ -381,11 +381,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)?) } } @@ -502,7 +502,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -534,7 +534,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -593,7 +593,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -648,7 +648,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -676,7 +676,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -704,7 +704,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -732,7 +732,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -764,7 +764,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -798,7 +798,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -832,7 +832,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -862,7 +862,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -893,7 +893,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -925,7 +925,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); @@ -951,7 +951,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 bf489f8..5b16546 100644 --- a/crates/http-service/src/executor/wasi_http.rs +++ b/crates/http-service/src/executor/wasi_http.rs @@ -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 @@ -147,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)?; @@ -162,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()); } }; @@ -180,7 +185,11 @@ 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()), }; diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index 471d956..2355b0d 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -27,7 +27,7 @@ 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; @@ -221,7 +221,7 @@ 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) })?; @@ -413,7 +413,7 @@ where let caller_ip = request .headers() - .get(crate::executor::X_REAL_IP) + .get(executor::X_REAL_IP) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) .unwrap_or(std::net::Ipv4Addr::UNSPECIFIED); @@ -477,7 +477,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 { @@ -509,15 +509,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 { @@ -526,7 +526,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, ) } @@ -537,7 +537,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 => ( @@ -545,7 +545,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, ), _ => ( @@ -553,7 +553,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, ), } @@ -563,7 +563,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") { @@ -572,7 +572,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 { @@ -581,7 +581,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, ) }; @@ -608,7 +608,7 @@ fn internal_fastedge_error( .body( Full::new(Bytes::from(format!("fastedge: {}", msg))) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), )?) } @@ -619,7 +619,7 @@ fn not_found() -> Result> { .body( Full::new(Bytes::from("fastedge: Unknown app")) .map_err(|never| match never {}) - .boxed(), + .boxed_unsync(), )?) } @@ -627,14 +627,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)] @@ -789,7 +789,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -806,7 +806,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -823,7 +823,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -840,7 +840,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)); @@ -858,7 +858,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -876,7 +876,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); let app_name = assert_ok!(app_name_from_request(&req)); @@ -892,7 +892,7 @@ mod tests { .body( Empty::::new() .map_err(|never| match never {}) - .boxed() + .boxed_unsync() ) ); assert_err!(app_name_from_request(&req)); @@ -906,7 +906,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)); diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 6fbfe1e..8d86340 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -11,7 +11,6 @@ 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 } @@ -19,6 +18,7 @@ wasmtime-wasi-io = { workspace = true } smol_str = { workspace = true } moka = { workspace = true } http = { workspace = true } +http-body-util = "0.1" wit-component = "0.246.2" tracing = { workspace = true } bytesize = { workspace = true } diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 156b90c..8f96eb1 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -10,7 +10,10 @@ 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; @@ -44,16 +47,13 @@ use crate::util::stats::StatsVisitor; use anyhow::{anyhow, bail}; pub use app::{App, SecretValue, SecretValues}; use http::request::Parts; -use http::{HeaderName, Request, header}; +use http::{HeaderName, Request, Response, header}; +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; @@ -100,14 +100,48 @@ 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, // memory usage limiter @@ -121,13 +155,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, @@ -146,13 +173,13 @@ pub trait BackendRequest { 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 } } @@ -162,19 +189,33 @@ impl IoView for Data { } } +/// 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>; + impl WasiHttpView for Data { - fn ctx(&mut self) -> &mut WasiHttpCtx { - &mut self.http + fn http(&mut self) -> WasiHttpCtxView<'_> { + WasiHttpCtxView { + ctx: &mut self.http, + table: &mut self.table, + hooks: &mut self.hooks, + } } +} +impl WasiHttpHooks for HttpHooks { fn send_request( &mut self, - request: Request, - config: OutgoingRequestConfig, - ) -> HttpResult - where - Self: Sized, - { + 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 @@ -184,23 +225,28 @@ impl WasiHttpView for Data { let log_handle = self.inner.ext_request_log_handle(); 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 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; - let handle = wasmtime_wasi::runtime::spawn(async move { - let _stats_timer = ExtStatsTimer::new(stats); // keep timer alive until request is done + Box::new(async move { + let _stats_timer = ExtStatsTimer::new(stats); // keep timer alive until response head is in let started = Instant::now(); - let resp = - default_send_request_handler(request, OutgoingRequestConfig { use_tls, ..config }) - .await; + 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); @@ -208,19 +254,18 @@ impl WasiHttpView for Data { // 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 &resp { - Ok(incoming) => incoming.resp.status().as_u16(), + let status = match &sent { + Ok((incoming, _)) => incoming.status().as_u16(), Err(_) => 0, }; handle.log(&log_method, &log_uri, status, elapsed); } - Ok(resp) - }); - Ok(HostFutureIncomingResponse::pending(handle)) - } - - fn table(&mut self) -> &mut ResourceTable { - &mut self.table + let (response, io) = sent?; + Ok(( + response.map(BodyExt::boxed_unsync), + Box::new(io) as HttpIoFuture, + )) + }) } fn is_forbidden_header(&mut self, name: &HeaderName) -> bool { @@ -235,12 +280,12 @@ 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) } } 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"), @@ -301,10 +346,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 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/store.rs b/crates/runtime/src/store.rs index 3bc9932..162ae55 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -2,7 +2,7 @@ use crate::limiter::ProxyLimiter; use crate::logger::Logger; use crate::registry::CachedGraphRegistry; 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; @@ -353,7 +353,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 +374,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 +385,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 +496,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)), } }); diff --git a/src/context.rs b/src/context.rs index c06c9cb..1bc6481 100644 --- a/src/context.rs +++ b/src/context.rs @@ -42,7 +42,7 @@ pub struct Context { 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 { From d136779a44c1309837903f1dc3e9e94aeb777aba Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Tue, 22 Sep 2026 12:29:19 +0300 Subject: [PATCH 23/29] feat(runtime): add epoch refund for precise HTTP wait time accounting - Introduced `EpochRefund`, `ResponseBodyEpochRefund`, and `RequestBodyEpochRefund` to improve epoch pause accounting for network wait times during HTTP request and response processing. - Enhanced `WasiHttpHooks` to ensure accurate epoch refunds for guest blocking on network I/O. - Updated `Cargo.toml` and `Cargo.lock` to include `http-body` dependency. --- Cargo.lock | 1 + crates/runtime/Cargo.toml | 1 + crates/runtime/src/lib.rs | 224 ++++++++++++++++++++++++++++++++++---- 3 files changed, 205 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea362f8..80ec5ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2776,6 +2776,7 @@ dependencies = [ "claims", "http", "http-backend", + "http-body", "http-body-util", "key-value-store", "moka", diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 8d86340..6bb6bb2 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -18,6 +18,7 @@ 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 } diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 8f96eb1..06b1698 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -3,9 +3,11 @@ use crate::store::HasStats; use http_backend::access_log::ExtRequestLogHandle; use http_backend::stats::ExtStatsTimer; use std::net::Ipv4Addr; -use std::sync::Arc; +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; @@ -46,8 +48,10 @@ 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, Response, header}; +use http_body::{Body, Frame, SizeHint}; use http_body_util::BodyExt; use secret::SecretStore; use smol_str::SmolStr; @@ -193,6 +197,156 @@ impl IoView for Data { /// 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 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); + } + } +} + +/// 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()); + } + } + } + result + } + + 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 { @@ -204,6 +358,21 @@ impl WasiHttpView for Data { } 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) { + return false; + } + // Block all headers with the reserved `fastedge` prefix — these are + // internal routing headers that guest modules must not set. + let name_str = name.as_str(); + if name_str.starts_with("fastedge-") || name_str.starts_with("fastedge_") { + return true; + } + // Fall back to wasmtime's default forbidden-header policy. + DEFAULT_FORBIDDEN_HEADERS.contains(name) + } + fn send_request( &mut self, request: Request, @@ -245,11 +414,28 @@ impl WasiHttpHooks for HttpHooks { 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). @@ -261,27 +447,23 @@ impl WasiHttpHooks for HttpHooks { handle.log(&log_method, &log_uri, status, elapsed); } let (response, io) = sent?; - Ok(( - response.map(BodyExt::boxed_unsync), - Box::new(io) as HttpIoFuture, - )) + // 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)) }) } - - fn is_forbidden_header(&mut self, name: &HeaderName) -> bool { - // We want to allow the host header to be set. - if name.eq(&header::HOST) { - return false; - } - // Block all headers with the reserved `fastedge` prefix — these are - // internal routing headers that guest modules must not set. - let name_str = name.as_str(); - if name_str.starts_with("fastedge-") || name_str.starts_with("fastedge_") { - return true; - } - // Fall back to wasmtime's default forbidden-header policy. - DEFAULT_FORBIDDEN_HEADERS.contains(name) - } } impl Data { From e0ed9ab9e4162426b8a7fb691124706ba8524d77 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Mon, 28 Sep 2026 15:52:25 +0300 Subject: [PATCH 24/29] chore(deps): update Cargo.lock and workflows for dependency consistency and locked builds - Updated `Cargo.lock` with new `wasmtime` commit references. - Adjusted GitHub Actions workflow to include `--locked` flag for `cargo deb` commands. --- Cargo.lock | 175 ++++++++++++++++++++--------------------------------- 1 file changed, 65 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80ec5ba..b59798d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,7 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-assembler-x64-meta", ] @@ -537,6 +538,7 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64-meta" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-srcgen", ] @@ -544,51 +546,35 @@ dependencies = [ [[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", - "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#850554ec220d340359818f5c6dacef945b4def05" -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.135.2" -dependencies = [ - "serde", - "serde_derive", - "wasmtime-internal-core 48.0.2", + "cranelift-entity", + "wasmtime-internal-core", ] [[package]] name = "cranelift-bitset" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#850554ec220d340359818f5c6dacef945b4def05" +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)", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "bumpalo", "cranelift-assembler-x64", - "cranelift-bforest 0.135.2", - "cranelift-bitset 0.135.2", + "cranelift-bforest", + "cranelift-bitset", "cranelift-codegen-meta", "cranelift-codegen-shared", "cranelift-control", - "cranelift-entity 0.135.2", + "cranelift-entity", "cranelift-isle", "gimli", "hashbrown 0.17.1", @@ -603,12 +589,13 @@ dependencies = [ "sha2", "smallvec", "target-lexicon", - "wasmtime-internal-core 48.0.2", + "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen-meta" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -620,10 +607,12 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[package]] name = "cranelift-control" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "arbitrary", ] @@ -631,27 +620,18 @@ dependencies = [ [[package]] name = "cranelift-entity" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ - "cranelift-bitset 0.135.2", - "serde", - "serde_derive", - "wasmtime-internal-core 48.0.2", -] - -[[package]] -name = "cranelift-entity" -version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#850554ec220d340359818f5c6dacef945b4def05" -dependencies = [ - "cranelift-bitset 0.135.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "cranelift-bitset", "serde", "serde_derive", - "wasmtime-internal-core 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "wasmtime-internal-core", ] [[package]] name = "cranelift-frontend" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-codegen", "hashbrown 0.17.1", @@ -663,10 +643,12 @@ dependencies = [ [[package]] name = "cranelift-isle" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[package]] name = "cranelift-native" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-codegen", "libc", @@ -676,6 +658,7 @@ dependencies = [ [[package]] name = "cranelift-srcgen" version = "0.135.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[package]] name = "crc32fast" @@ -2485,16 +2468,18 @@ dependencies = [ [[package]] name = "pulley-interpreter" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ - "cranelift-bitset 0.135.2", + "cranelift-bitset", "log", "pulley-macros", - "wasmtime-internal-core 48.0.2", + "wasmtime-internal-core", ] [[package]] name = "pulley-macros" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "proc-macro2", "quote", @@ -2790,10 +2775,10 @@ dependencies = [ "tracing", "utils", "wasmtime", - "wasmtime-environ 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "wasmtime-environ", "wasmtime-wasi", "wasmtime-wasi-http", - "wasmtime-wasi-io 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "wasmtime-wasi-io", "wasmtime-wasi-nn", "wat", "wit-component 0.246.2", @@ -3868,6 +3853,7 @@ dependencies = [ [[package]] name = "wasmtime" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "addr2line", "async-trait", @@ -3899,11 +3885,11 @@ dependencies = [ "wasm-compose", "wasm-encoder 0.254.0", "wasmparser 0.254.0", - "wasmtime-environ 48.0.2", + "wasmtime-environ", "wasmtime-internal-cache", "wasmtime-internal-component-macro", "wasmtime-internal-component-util", - "wasmtime-internal-core 48.0.2", + "wasmtime-internal-core", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", @@ -3918,12 +3904,13 @@ dependencies = [ [[package]] name = "wasmtime-environ" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "anyhow", "cpp_demangle", - "cranelift-bforest 0.135.2", - "cranelift-bitset 0.135.2", - "cranelift-entity 0.135.2", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", "gimli", "hashbrown 0.17.1", "indexmap", @@ -3941,36 +3928,13 @@ dependencies = [ "wasmparser 0.254.0", "wasmprinter", "wasmtime-internal-component-util", - "wasmtime-internal-core 48.0.2", -] - -[[package]] -name = "wasmtime-environ" -version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#850554ec220d340359818f5c6dacef945b4def05" -dependencies = [ - "anyhow", - "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.254.0", - "wasmtime-internal-core 48.0.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", + "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-cache" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "base64", "directories-next", @@ -3981,7 +3945,7 @@ dependencies = [ "serde_derive", "sha2", "toml", - "wasmtime-environ 48.0.2", + "wasmtime-environ", "windows-sys 0.61.2", "zstd", ] @@ -3989,6 +3953,7 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "anyhow", "proc-macro2", @@ -4002,10 +3967,12 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[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 = [ "anyhow", "hashbrown 0.17.1", @@ -4013,23 +3980,14 @@ dependencies = [ "serde", ] -[[package]] -name = "wasmtime-internal-core" -version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#850554ec220d340359818f5c6dacef945b4def05" -dependencies = [ - "hashbrown 0.17.1", - "libm", - "serde", -] - [[package]] name = "wasmtime-internal-cranelift" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-codegen", "cranelift-control", - "cranelift-entity 0.135.2", + "cranelift-entity", "cranelift-frontend", "cranelift-native", "gimli", @@ -4041,8 +3999,8 @@ dependencies = [ "target-lexicon", "thiserror 2.0.19", "wasmparser 0.254.0", - "wasmtime-environ 48.0.2", - "wasmtime-internal-core 48.0.2", + "wasmtime-environ", + "wasmtime-internal-core", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] @@ -4050,11 +4008,12 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cc", "libc", "rustix", - "wasmtime-environ 48.0.2", + "wasmtime-environ", "wasmtime-internal-versioned-export-macros", "windows-sys 0.61.2", ] @@ -4062,6 +4021,7 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cc", "object", @@ -4072,25 +4032,28 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "libc", - "wasmtime-internal-core 48.0.2", + "wasmtime-internal-core", "windows-sys 0.61.2", ] [[package]] name = "wasmtime-internal-unwinder" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-codegen", "log", "object", - "wasmtime-environ 48.0.2", + "wasmtime-environ", ] [[package]] name = "wasmtime-internal-versioned-export-macros" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "proc-macro2", "quote", @@ -4100,6 +4063,7 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -4112,6 +4076,7 @@ dependencies = [ [[package]] name = "wasmtime-wasi" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "async-trait", "bitflags 2.13.1", @@ -4126,7 +4091,7 @@ dependencies = [ "tracing", "url", "wasmtime", - "wasmtime-wasi-io 48.0.2", + "wasmtime-wasi-io", "wiggle", "windows-sys 0.61.2", "winx", @@ -4135,6 +4100,7 @@ dependencies = [ [[package]] name = "wasmtime-wasi-http" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "async-trait", "bytes", @@ -4150,25 +4116,14 @@ dependencies = [ "tracing", "wasmtime", "wasmtime-wasi", - "wasmtime-wasi-io 48.0.2", + "wasmtime-wasi-io", "webpki-roots", ] [[package]] name = "wasmtime-wasi-io" version = "48.0.2" -dependencies = [ - "async-trait", - "bytes", - "futures", - "tracing", - "wasmtime", -] - -[[package]] -name = "wasmtime-wasi-io" -version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#850554ec220d340359818f5c6dacef945b4def05" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "async-trait", "bytes", @@ -4180,6 +4135,7 @@ dependencies = [ [[package]] name = "wasmtime-wasi-nn" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "candle-core", "candle-nn", @@ -4236,30 +4192,33 @@ dependencies = [ [[package]] name = "wiggle" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "bitflags 2.13.1", "thiserror 2.0.19", "tracing", "wasmtime", - "wasmtime-environ 48.0.2", + "wasmtime-environ", "wiggle-macro", ] [[package]] name = "wiggle-generate" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "heck", "proc-macro2", "quote", "syn 2.0.119", - "wasmtime-environ 48.0.2", + "wasmtime-environ", "witx", ] [[package]] name = "wiggle-macro" version = "48.0.2" +source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "proc-macro2", "quote", @@ -4871,7 +4830,3 @@ dependencies = [ "cc", "pkg-config", ] - -[[patch.unused]] -name = "fastedge_proxywasm" -version = "0.5.0" From 83e8c2465bf3584c2593947fa9248729334a0393 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Thu, 1 Oct 2026 10:17:23 +0300 Subject: [PATCH 25/29] chore(runtime): upgrade Wasmtime to 48.0.2 and update dependency management - Updated `update-preview1.sh` for improved Wasmtime version tracking and script reliability. - Updated `Cargo.lock` to include Wasmtime 48.0.2 and dependency adjustments. - Refactored references to dependencies for consistency and removed outdated sources. --- Cargo.lock | 201 ++++++++++++------ .../wasi_snapshot_preview1.reactor.wasm | Bin 81115 -> 51634 bytes 2 files changed, 138 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b59798d..d378933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,7 +530,6 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-assembler-x64-meta", ] @@ -538,18 +537,34 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64-meta" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-srcgen", ] +[[package]] +name = "cranelift-bforest" +version = "0.135.2" +dependencies = [ + "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", - "wasmtime-internal-core", + "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.135.2" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core 48.0.2", ] [[package]] @@ -559,22 +574,21 @@ source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281 dependencies = [ "serde", "serde_derive", - "wasmtime-internal-core", + "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.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "bumpalo", "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset", + "cranelift-bforest 0.135.2", + "cranelift-bitset 0.135.2", "cranelift-codegen-meta", "cranelift-codegen-shared", "cranelift-control", - "cranelift-entity", + "cranelift-entity 0.135.2", "cranelift-isle", "gimli", "hashbrown 0.17.1", @@ -589,13 +603,12 @@ dependencies = [ "sha2", "smallvec", "target-lexicon", - "wasmtime-internal-core", + "wasmtime-internal-core 48.0.2", ] [[package]] name = "cranelift-codegen-meta" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -607,31 +620,38 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[package]] name = "cranelift-control" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "arbitrary", ] +[[package]] +name = "cranelift-entity" +version = "0.135.2" +dependencies = [ + "cranelift-bitset 0.135.2", + "serde", + "serde_derive", + "wasmtime-internal-core 48.0.2", +] + [[package]] name = "cranelift-entity" version = "0.135.2" source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ - "cranelift-bitset", + "cranelift-bitset 0.135.2 (git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0)", "serde", "serde_derive", - "wasmtime-internal-core", + "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.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-codegen", "hashbrown 0.17.1", @@ -643,12 +663,10 @@ dependencies = [ [[package]] name = "cranelift-isle" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[package]] name = "cranelift-native" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-codegen", "libc", @@ -658,7 +676,6 @@ dependencies = [ [[package]] name = "cranelift-srcgen" version = "0.135.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[package]] name = "crc32fast" @@ -2468,18 +2485,16 @@ dependencies = [ [[package]] name = "pulley-interpreter" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ - "cranelift-bitset", + "cranelift-bitset 0.135.2", "log", "pulley-macros", - "wasmtime-internal-core", + "wasmtime-internal-core 48.0.2", ] [[package]] name = "pulley-macros" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "proc-macro2", "quote", @@ -2533,13 +2548,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -2554,6 +2580,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2564,6 +2600,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -2765,7 +2810,7 @@ dependencies = [ "http-body-util", "key-value-store", "moka", - "rand 0.8.7", + "rand 0.8.8", "secret", "serde", "serde_json", @@ -2775,10 +2820,10 @@ dependencies = [ "tracing", "utils", "wasmtime", - "wasmtime-environ", + "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", + "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 0.246.2", @@ -3853,7 +3898,6 @@ dependencies = [ [[package]] name = "wasmtime" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "addr2line", "async-trait", @@ -3885,11 +3929,11 @@ dependencies = [ "wasm-compose", "wasm-encoder 0.254.0", "wasmparser 0.254.0", - "wasmtime-environ", + "wasmtime-environ 48.0.2", "wasmtime-internal-cache", "wasmtime-internal-component-macro", "wasmtime-internal-component-util", - "wasmtime-internal-core", + "wasmtime-internal-core 48.0.2", "wasmtime-internal-cranelift", "wasmtime-internal-fiber", "wasmtime-internal-jit-debug", @@ -3904,13 +3948,12 @@ dependencies = [ [[package]] name = "wasmtime-environ" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "anyhow", "cpp_demangle", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-entity", + "cranelift-bforest 0.135.2", + "cranelift-bitset 0.135.2", + "cranelift-entity 0.135.2", "gimli", "hashbrown 0.17.1", "indexmap", @@ -3928,13 +3971,36 @@ dependencies = [ "wasmparser 0.254.0", "wasmprinter", "wasmtime-internal-component-util", - "wasmtime-internal-core", + "wasmtime-internal-core 48.0.2", ] [[package]] -name = "wasmtime-internal-cache" +name = "wasmtime-environ" version = "48.0.2" source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" +dependencies = [ + "anyhow", + "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.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 = "48.0.2" dependencies = [ "base64", "directories-next", @@ -3945,7 +4011,7 @@ dependencies = [ "serde_derive", "sha2", "toml", - "wasmtime-environ", + "wasmtime-environ 48.0.2", "windows-sys 0.61.2", "zstd", ] @@ -3953,7 +4019,6 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "anyhow", "proc-macro2", @@ -3967,12 +4032,10 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" [[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 = [ "anyhow", "hashbrown 0.17.1", @@ -3981,13 +4044,22 @@ dependencies = [ ] [[package]] -name = "wasmtime-internal-cranelift" +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", + "cranelift-entity 0.135.2", "cranelift-frontend", "cranelift-native", "gimli", @@ -3999,8 +4071,8 @@ dependencies = [ "target-lexicon", "thiserror 2.0.19", "wasmparser 0.254.0", - "wasmtime-environ", - "wasmtime-internal-core", + "wasmtime-environ 48.0.2", + "wasmtime-internal-core 48.0.2", "wasmtime-internal-unwinder", "wasmtime-internal-versioned-export-macros", ] @@ -4008,12 +4080,11 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cc", "libc", "rustix", - "wasmtime-environ", + "wasmtime-environ 48.0.2", "wasmtime-internal-versioned-export-macros", "windows-sys 0.61.2", ] @@ -4021,7 +4092,6 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cc", "object", @@ -4032,28 +4102,25 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "libc", - "wasmtime-internal-core", + "wasmtime-internal-core 48.0.2", "windows-sys 0.61.2", ] [[package]] name = "wasmtime-internal-unwinder" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "cranelift-codegen", "log", "object", - "wasmtime-environ", + "wasmtime-environ 48.0.2", ] [[package]] name = "wasmtime-internal-versioned-export-macros" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "proc-macro2", "quote", @@ -4063,7 +4130,6 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -4076,7 +4142,6 @@ dependencies = [ [[package]] name = "wasmtime-wasi" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "async-trait", "bitflags 2.13.1", @@ -4091,7 +4156,7 @@ dependencies = [ "tracing", "url", "wasmtime", - "wasmtime-wasi-io", + "wasmtime-wasi-io 48.0.2", "wiggle", "windows-sys 0.61.2", "winx", @@ -4100,7 +4165,6 @@ dependencies = [ [[package]] name = "wasmtime-wasi-http" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "async-trait", "bytes", @@ -4116,10 +4180,21 @@ dependencies = [ "tracing", "wasmtime", "wasmtime-wasi", - "wasmtime-wasi-io", + "wasmtime-wasi-io 48.0.2", "webpki-roots", ] +[[package]] +name = "wasmtime-wasi-io" +version = "48.0.2" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + [[package]] name = "wasmtime-wasi-io" version = "48.0.2" @@ -4135,7 +4210,6 @@ dependencies = [ [[package]] name = "wasmtime-wasi-nn" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "candle-core", "candle-nn", @@ -4192,33 +4266,30 @@ dependencies = [ [[package]] name = "wiggle" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "bitflags 2.13.1", "thiserror 2.0.19", "tracing", "wasmtime", - "wasmtime-environ", + "wasmtime-environ 48.0.2", "wiggle-macro", ] [[package]] name = "wiggle-generate" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "heck", "proc-macro2", "quote", "syn 2.0.119", - "wasmtime-environ", + "wasmtime-environ 48.0.2", "witx", ] [[package]] name = "wiggle-macro" version = "48.0.2" -source = "git+https://github.com/G-Core/wasmtime.git?branch=release-48.0.0#ef281a08e10646e8b0592d0081752fef23cab585" dependencies = [ "proc-macro2", "quote", @@ -4830,3 +4901,7 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "fastedge_proxywasm" +version = "0.5.0" diff --git a/crates/runtime/src/adapters/wasi_snapshot_preview1.reactor.wasm b/crates/runtime/src/adapters/wasi_snapshot_preview1.reactor.wasm index 89b68cf7ac50edc2e978840d5081a58098c49d31..ed39fa8e72f345996715831d3385ecfa21c2df27 100644 GIT binary patch literal 51634 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

If|)c*&C~WId%%J7-eyG=!9CKx$Z*y+@~1%sRt+ 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 literal 81115 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 Date: Mon, 5 Oct 2026 11:08:30 +0300 Subject: [PATCH 26/29] feat(stats): add support for excluding requests from specific User-Agents in stats --- crates/http-service/src/lib.rs | 37 ++++++++++++++++++++++++++++++++ crates/runtime/src/lib.rs | 7 ++++++ crates/runtime/src/util/stats.rs | 4 ++++ 3 files changed, 48 insertions(+) diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index 471d956..f7017fb 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -420,6 +420,13 @@ where 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) => { @@ -466,6 +473,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 @@ -777,6 +795,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")] diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 156b90c..a831b49 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -473,6 +473,13 @@ pub trait ContextT { 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`]. diff --git a/crates/runtime/src/util/stats.rs b/crates/runtime/src/util/stats.rs index ca5abd2..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 { From dc6dd45881e5ef4382dfc917c22c23f13ac59e0c Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 2 Oct 2026 11:59:41 +0300 Subject: [PATCH 27/29] feat(wasi-nn): implement process-wide graph caching and preload mechanism --- crates/runtime/src/lib.rs | 7 +- crates/runtime/src/registry.rs | 237 ++++++++++++++++++++++++++------- crates/runtime/src/store.rs | 125 ++++++++++++----- src/context.rs | 1 + 4 files changed, 284 insertions(+), 86 deletions(-) diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 8e7ff6e..6a47211 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -583,6 +583,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 @@ -595,6 +598,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(), } } } @@ -615,7 +619,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`]. @@ -658,6 +662,7 @@ impl WasmEngineBuilder { inner: self.engine, component_linker: self.component_linker, module_linker: self.module_linker, + graph_registry: crate::registry::CachedGraphRegistry::new(), } } } diff --git a/crates/runtime/src/registry.rs b/crates/runtime/src/registry.rs index 38c9c81..d0bfdea 100644 --- a/crates/runtime/src/registry.rs +++ b/crates/runtime/src/registry.rs @@ -1,22 +1,50 @@ +//! 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`], shared by every +//! `StoreBuilder` of a `WasmEngine`, 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 anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Result, anyhow, bail}; use moka::sync::Cache; use wasmtime_wasi_nn::backend::candle::CandleBackend; 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}, }; +/// `(backend kind, graph directory)` pairs an app asks to have loaded. +pub type PreloadSpec = Vec<(String, String)>; + +/// Process-wide cache of loaded graphs, keyed by graph name. +/// +/// Cheap to clone (the cache is reference-counted); one instance lives on the +/// `WasmEngine` and is handed to every `StoreBuilder` it creates. #[derive(Clone)] 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,6 +64,61 @@ 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 { let builder = Cache::builder() @@ -47,56 +130,108 @@ impl CachedGraphRegistry { Self(builder.build()) } - /// Load a graph from the files contained in the `path` directory. + /// Resolves every graph in `spec`, loading the ones not cached yet. /// - /// 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() - ); - } - 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) + /// Concurrent requests for the same graph wait for a single load instead of + /// each running one (`try_get_with`): a cold graph used to be loaded once + /// per concurrent miss, each copy costing the full model footprint. + pub fn load_graphs(&self, spec: &[(String, String)]) -> Result> { + spec.iter() + .map(|(kind, path)| { + let name = graph_name(path)?; + let graph = self + .0 + .try_get_with(name.clone(), || load_graph(kind, path)) + .map_err(|error| anyhow!("wasi-nn backend load {}: {:#}", path, error))?; + Ok((name, graph)) + }) + .collect() + } + + /// Builds a `Store`'s view of already-resolved graphs. Only `Arc` clones. + pub fn registry_for(graphs: &[(String, Graph)]) -> Registry { + Registry::from(StoreRegistry(graphs.iter().cloned().collect())) + } +} + +#[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}"); } - 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((backends, Registry::from(registry))) + #[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/store.rs b/crates/runtime/src/store.rs index 162ae55..057fe67 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -18,6 +18,7 @@ use wasmtime::UpdateDeadline; use wasmtime::component::ResourceTable; use wasmtime_wasi::WasiCtxBuilder; use wasmtime_wasi_http::WasiHttpCtx; +use wasmtime_wasi_nn::Graph; use wasmtime_wasi_nn::wit::WasiNnCtx; /// Default extra wall-clock budget (in ms) added to the tokio timeout that @@ -126,7 +127,12 @@ 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; then + /// `build` falls back to loading on the spot. + 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,37 @@ 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); - - let (backends, registry) = self.registry.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 + }) + } - Ok(WasiNnCtx::new(backends, registry)) + pub fn make_wasi_nn(&self) -> Result { + let registry = match &self.nn_graphs { + Some(graphs) => CachedGraphRegistry::registry_for(graphs), + // Builder not preloaded (the CLI, tests): resolve on the spot. + // Cached graphs are only cloned; a cold one is loaded right here, + // on whatever thread is building the store. + None => { + let spec = crate::registry::preload_spec(&self.env); + debug!("preload graphs (not preloaded by the executor): {:?}", spec); + CachedGraphRegistry::registry_for(&self.registry.load_graphs(&spec)?) + } + }; + Ok(WasiNnCtx::new(crate::registry::backends(), registry)) } /// Builds a [`Store`] from this builder with `Default` WasiCtxBuilder @@ -651,16 +664,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); @@ -675,4 +692,44 @@ 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 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/src/context.rs b/src/context.rs index 1bc6481..fbbed46 100644 --- a/src/context.rs +++ b/src/context.rs @@ -125,6 +125,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) From ef3eca6894897179253c62853e323e4410a9b7c5 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 9 Oct 2026 09:10:01 +0300 Subject: [PATCH 28/29] feat(runtime): enhance graph caching with process-wide registry and context handling --- Cargo.lock | 1 + crates/http-service/src/executor/http.rs | 4 + crates/http-service/src/lib.rs | 7 +- crates/runtime/Cargo.toml | 1 + crates/runtime/src/lib.rs | 24 ++- crates/runtime/src/registry.rs | 232 ++++++++++++++++++++--- crates/runtime/src/service.rs | 1 + crates/runtime/src/store.rs | 58 ++++-- src/context.rs | 39 +++- src/main.rs | 5 +- 10 files changed, 318 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d378933..fc4e979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2815,6 +2815,7 @@ dependencies = [ "serde", "serde_json", "smol_str", + "tempfile", "tokio", "tokio-util", "tracing", diff --git a/crates/http-service/src/executor/http.rs b/crates/http-service/src/executor/http.rs index 9f94ab2..6997297 100644 --- a/crates/http-service/src/executor/http.rs +++ b/crates/http-service/src/executor/http.rs @@ -351,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!() } diff --git a/crates/http-service/src/lib.rs b/crates/http-service/src/lib.rs index cc64468..6e75a35 100644 --- a/crates/http-service/src/lib.rs +++ b/crates/http-service/src/lib.rs @@ -223,7 +223,12 @@ where linker.allow_shadowing(true); 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(()) } diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 6bb6bb2..89d404f 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -38,6 +38,7 @@ chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] claims = "0.8" wat = "1" +tempfile = "3" [lints] workspace = true diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 6a47211..0abd979 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -40,6 +40,7 @@ pub mod stub; pub mod trace; pub mod util; +pub use registry::CachedGraphRegistry; pub use trace::new_traceparent; use crate::access_log::{AccessLogSender, NoopAccessLogSender}; @@ -147,7 +148,10 @@ impl HttpHooks { pub struct Data { 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, @@ -610,6 +614,7 @@ pub struct WasmEngineBuilder { engine: Engine, component_linker: ComponentLinker, module_linker: ModuleLinker, + graph_registry: crate::registry::CachedGraphRegistry, } impl WasmEngine { @@ -645,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 } @@ -662,7 +676,7 @@ impl WasmEngineBuilder { inner: self.engine, component_linker: self.component_linker, module_linker: self.module_linker, - graph_registry: crate::registry::CachedGraphRegistry::new(), + graph_registry: self.graph_registry, } } } @@ -691,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; diff --git a/crates/runtime/src/registry.rs b/crates/runtime/src/registry.rs index d0bfdea..d39dce7 100644 --- a/crates/runtime/src/registry.rs +++ b/crates/runtime/src/registry.rs @@ -2,8 +2,9 @@ //! //! 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`], shared by every -//! `StoreBuilder` of a `WasmEngine`, and an executor resolves its app's graphs +//! 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`. @@ -14,12 +15,13 @@ //! The guest refers to the graph by that directory's last path component. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Result, anyhow, bail}; 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, @@ -28,15 +30,64 @@ use wasmtime_wasi_nn::{ {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)>; -/// Process-wide cache of loaded graphs, keyed by graph name. +/// 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, + }) + } +} + +/// 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 -/// `WasmEngine` and is handed to every `StoreBuilder` it creates. +/// 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 { @@ -121,36 +172,73 @@ fn load_graph(kind: &str, path: &str) -> Result { 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 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); Self(builder.build()) } + /// 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. /// /// Concurrent requests for the same graph wait for a single load instead of /// each running one (`try_get_with`): a cold graph used to be loaded once - /// per concurrent miss, each copy costing the full model footprint. - pub fn load_graphs(&self, spec: &[(String, String)]) -> Result> { - spec.iter() - .map(|(kind, path)| { - let name = graph_name(path)?; - let graph = self - .0 - .try_get_with(name.clone(), || load_graph(kind, path)) - .map_err(|error| anyhow!("wasi-nn backend load {}: {:#}", path, error))?; - Ok((name, graph)) - }) - .collect() + /// per concurrent miss, each copy costing the full model footprint. The + /// handles returned keep the 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 graph = self + .0 + .try_get_with(key.clone(), || load_graph(kind, path)) + .map_err(|error| anyhow!("wasi-nn backend load {}: {:#}", path, error))?; + graphs.push(LoadedGraph { key, name, graph }); + } + Ok(graphs) } /// Builds a `Store`'s view of already-resolved graphs. Only `Arc` clones. - pub fn registry_for(graphs: &[(String, Graph)]) -> Registry { - Registry::from(StoreRegistry(graphs.iter().cloned().collect())) + 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)) } } @@ -229,6 +317,106 @@ mod tests { 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) + } + } + + fn stub_graph() -> Graph { + Graph::from(Box::new(StubGraph) as Box) + } + + /// 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(), stub_graph()); + let held = vec![LoadedGraph { + key: key.clone(), + name: "m".into(), + graph: registry.0.get(&key).unwrap(), + }]; + + // 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)); + registry.0.insert( + GraphKey::new("openvino", a.to_str().unwrap()).unwrap(), + 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(&[]); 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 057fe67..0c87536 100644 --- a/crates/runtime/src/store.rs +++ b/crates/runtime/src/store.rs @@ -1,6 +1,6 @@ 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, HttpHooks, Wasi, WasiVersion}; use anyhow::Result; @@ -18,7 +18,6 @@ use wasmtime::UpdateDeadline; use wasmtime::component::ResourceTable; use wasmtime_wasi::WasiCtxBuilder; use wasmtime_wasi_http::WasiHttpCtx; -use wasmtime_wasi_nn::Graph; use wasmtime_wasi_nn::wit::WasiNnCtx; /// Default extra wall-clock budget (in ms) added to the tokio timeout that @@ -130,9 +129,10 @@ pub struct StoreBuilder { /// 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; then - /// `build` falls back to loading on the spot. - nn_graphs: Option>, + /// 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, @@ -297,19 +297,22 @@ impl StoreBuilder { }) } - pub fn make_wasi_nn(&self) -> Result { - let registry = match &self.nn_graphs { - Some(graphs) => CachedGraphRegistry::registry_for(graphs), - // Builder not preloaded (the CLI, tests): resolve on the spot. - // Cached graphs are only cloned; a cold one is loaded right here, - // on whatever thread is building the store. - None => { - let spec = crate::registry::preload_spec(&self.env); - debug!("preload graphs (not preloaded by the executor): {:?}", spec); - CachedGraphRegistry::registry_for(&self.registry.load_graphs(&spec)?) - } - }; - Ok(WasiNnCtx::new(crate::registry::backends(), registry)) + /// 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() + } + + /// 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 @@ -334,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); @@ -713,6 +716,23 @@ mod tests { .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] diff --git a/src/context.rs b/src/context.rs index fbbed46..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,9 +34,10 @@ 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 { @@ -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 { @@ -101,15 +106,15 @@ impl ContextT for Context { } } -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()); @@ -156,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!() 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()?; From dbc35a6e82f7bacfb8369c4b239fa5961b3184b5 Mon Sep 17 00:00:00 2001 From: Ruslan Pislari Date: Fri, 9 Oct 2026 09:50:16 +0300 Subject: [PATCH 29/29] feat(wasi-nn): detect in-place model updates and log graph loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-wide cache keyed a graph by backend + canonical directory, so a model replaced in place (rsync/cp over the same files) was never seen: every executor rebuild hit the cached graph and `mark_used` kept it alive as long as there was traffic, serving the old model until restart. On `main` the per-executor registry re-read the files on each rebuild. Each cache entry now carries a `Revision` of the directory's files (name, size, mtime — one `stat` per file, no hashing of the weights). Preload compares it under moka's per-key `and_try_compute_with`: same revision is a hit, a different one replaces the entry atomically, so the new model is loaded at the next executor rebuild and the old graph is freed once the executors built on it are gone — no second copy lingering for the 4 h idle timeout. Symlink swaps were already covered by canonicalisation. Loads are rare and cost seconds and gigabytes, so each one is logged at info: backend, path, guest name, bytes on disk, RSS delta (Linux), load time and whether it replaced a stale revision. Evictions log their cause (idle, replaced, capacity); cache hits log at debug. --- crates/runtime/src/registry.rs | 189 ++++++++++++++++++++++++++++++--- 1 file changed, 174 insertions(+), 15 deletions(-) diff --git a/crates/runtime/src/registry.rs b/crates/runtime/src/registry.rs index d39dce7..6ebf770 100644 --- a/crates/runtime/src/registry.rs +++ b/crates/runtime/src/registry.rs @@ -15,10 +15,13 @@ //! The guest refers to the graph by that directory's last path component. use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::sync::Arc; +use std::time::{Duration, Instant}; -use anyhow::{Result, anyhow, bail}; +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; @@ -64,6 +67,70 @@ impl GraphKey { } } +/// 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)] @@ -87,7 +154,7 @@ pub struct LoadedGraph { /// 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 { @@ -179,7 +246,18 @@ impl CachedGraphRegistry { let builder = Cache::builder() // Max 100 entries .max_capacity(100) - .time_to_idle(time_to_idle); + .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()) } @@ -194,13 +272,16 @@ impl CachedGraphRegistry { } } - /// Resolves every graph in `spec`, loading the ones not cached yet. + /// Resolves every graph in `spec`, loading the ones not cached yet — or + /// cached from files that have since changed ([`Revision`]). /// - /// Concurrent requests for the same graph wait for a single load instead of - /// each running one (`try_get_with`): a cold graph used to be loaded once - /// per concurrent miss, each copy costing the full model footprint. The - /// handles returned keep the graph alive for the executor's lifetime; the - /// cache entry's own lifetime is a matter of traffic (see [`Self::mark_used`]). + /// 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 { @@ -216,10 +297,44 @@ impl CachedGraphRegistry { path ); } + let files = DirFiles::scan(&key.path)?; + let revision = files.revision; let graph = self .0 - .try_get_with(key.clone(), || load_graph(kind, path)) - .map_err(|error| anyhow!("wasi-nn backend load {}: {:#}", path, error))?; + .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 }); } Ok(graphs) @@ -335,6 +450,45 @@ mod tests { 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. @@ -346,11 +500,11 @@ mod tests { kind: "stub".into(), path: "/models/m".into(), }; - registry.0.insert(key.clone(), stub_graph()); + 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: registry.0.get(&key).unwrap().graph, }]; // Busy for 3x the idle timeout, touched every third of it. @@ -402,9 +556,14 @@ mod tests { 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(), - stub_graph(), + CachedGraph { + revision: DirFiles::scan(&a).unwrap().revision, + graph: stub_graph(), + }, ); let error = registry .load_graphs(&[