Prhub

#39014 [Router] Count open HTTP exchanges until their response body finishes

原始 PR 作者 Kangyan-Zhou 合并时间 2026-09-16 15:04 文件变更 8 提交数 1 评论 0 代码增减 +335 / -1

执行摘要

Router 新增在途 HTTP 计数,随响应体结束才释放

PR body 明确指出动机:ActiveLoadRegistry::inflight_count 回答的是另一个问题 —— 它只数 proxied requests,因此既看不到非代理路由上的请求,更关键的是看不到「handler 已返回但正文仍在流式写入」的补全请求。作者的原话是:"A chat completion hands back its headers immediately and then writes for minutes; a counter released at handler exit reads 0 for that entire window." 而 axum::serve(...).with_graceful_shutdown(...) 等待的恰恰是后者,目前没有任何指标上报它,导致只报 active_load 的心跳会对一个「几分钟后将被 SIGKILL 且仍有在途工作」的 pod 说 "0 in flight"。此外,PR 顺带修复 main 上 error[E0063]: missing field sampling_overrides in initializer of ModelConfig 的编译失败:fixture(#39006)与 sampling_overrides 字段(#39000)在同一时间窗落地,各自对当时的 main 是绿的,导致 cargo test 与 cargo clippy --all-targets 在所有 router 分支上失败。

值得精读,但只需聚焦两个文件:src/server/inflight.rs(计数原语与 RAII 守卫的设计,全文是标准「streaming 会计」范式)与 src/server/app.rs 的 count_requests(唯一的接线点,一行 resp.map(...) 承载了全部语义)。阅读时重点回答三个问题:(1) 为什么守卫必须挂在响应体而不是 handler future;(2) 为什么必须是 Drop 而不是显式 decrement(客户端中途断开);(3) 为什么 Relaxed 足够。测试部分值得看的是「非空性验证」思路 —— 作者通过把守卫改回 handler 出口释放来证明测试不是空断言,这种自我证伪值得借鉴。追 #39016 时建议回看本 PR,确认 drain 的等待条件确实基于这条 gauge,并补充 gzip / timeout 层下的在途观测。夹带的 pd_protocol_binding.rs 修复可略读。

讨论亮点

本 PR 只有 1 条 review(sherlockwu,APPROVED,正文为空),没有任何 inline review comment,因此讨论内容主要来自 PR body 与源码文档注释中的自我论证,值得记录的有三点:

"This is the number axum::serve(...).with_graceful_shutdown(...) actually waits on, and nothing currently reports it." —— 明确了与 ActiveLoadRegistry::inflight_count 的职责边界,是整段设计的立论。

"a client that disconnects mid-stream must not ratchet the gauge up for the life of the process" —— 解释了为什么用 RAII 守卫而不是显式 increment/decrement,这是本 PR 中最容易被后续改动破坏的约束。

"Verified non-vacuous: releasing the guard at handler exit instead of on body completion fails inflight_http_counts_a_streaming_response_until_its_body_finishes with left: 0, right: 1." —— 作者主动给出了反向验证证据,弥补了 review 讨论不足。

此外 PR body 交代了 stack 关系(1/3 本 PR、2/3 #39015、3/3 #39016,原 #39017 的 k8s e2e 已并入 #39016),并说明夹带的 pd_protocol_binding.rs 修复是两个 PR 在同窗口落地导致的 main 断编译,属于必要的解锁动作。

实现拆解

  1. 新增计数原语模块 experimental/sgl-router/src/server/inflight.rs(+178,全新文件):InflightHttp 持有一个 AtomicUsize(Relaxed 语义,注释说明这是诊断量纲,读者最多晚一个 tick 看到增量而不会读到撕裂值);enter() 返回 InflightGuard,Drop 中 fetch_sub,因此请求 future 在响应构造前被取消时也能正确回退;TrackedBody 包装 axum::body::Body 并持有守卫,poll_frame 直接把 Unpin 的内部 body 借用转发(无需 pin-project),is_end_stream / size_hint 透传;track_body(body, guard) 是唯一构造入口。
  2. 边缘中间件接入 experimental/sgl-router/src/server/app.rs:count_requests 是 build_router 上唯一能看到「每条路由、每次交换」的位置。改动是在 record_ingress 之后 let inflight = ctx.inflight_http.enter();,并在 next.run(req).await 之后用 resp.map(|body| track_body(body, inflight)) 把守卫从中间件作用域搬进响应体 —— 最后一行替换了原来直接返回 resp 的写法,这是本 PR 唯一的语义承载点。
  3. 上下文与导出的接线:app_context.rs 新增 pub inflight_http: Arc<InflightHttp> 字段(文档注释点明 active_load 只覆盖被代理的子集),在 with_active_load 与 #[cfg(test)] stub() 两处构造点都初始化为 InflightHttp::new();server/mod.rs 增加 pub mod inflight;。
  4. 依赖与锁文件:Cargo.toml 新增 http-body = "1",注释解释 Frame / SizeHint 不经 axum::body 重导出,必须直接命名依赖;Cargo.lock 相应把 http-body 加入 crate 依赖列表。
  5. 测试配套(源码与测试同步):inflight.rs 内嵌 4 个单元测试(守卫 drop 释放、流式 body 保持计数直到最后一帧、被放弃的 body 释放计数、普通 body 原样透传);tests/proxy/graceful_shutdown.rs 新增 wait_for_inflight_http 轮询助手(避免与服务端任务上稍后发生的 drop 竞争)与两个真实 build_router 栈上的测试:inflight_http_counts_a_streaming_response_until_its_body_finishes(header 到手、正文还有 ~480 ms 时断言计数为 1)与 inflight_http_returns_to_zero_after_non_proxied_routes(遍历 /metrics、/readyz、/healthz、/v1/models、/nope 后计数回 0)。作者声明做了非空性验证:把守卫改回在 handler 出口释放时,第一个测试会以 left: 0, right: 1 失败。
  6. 夹带的编译修复:tests/proxy/pd_protocol_binding.rs 的 config() 补上 sampling_overrides: Default::default(),使整条 router 分支的 cargo test / cargo clippy 恢复可用;该改动与仪表逻辑无关,但为让本 PR 变绿而必须携带。
文件 模块 状态 重要度
experimental/sgl-router/src/server/inflight.rs 在途计数 added 9.17
experimental/sgl-router/src/server/app.rs 边缘中间件 modified 6.0
experimental/sgl-router/src/server/app_context.rs 应用上下文 modified 5.96
experimental/sgl-router/tests/proxy/graceful_shutdown.rs 优雅关闭测试 modified 7.01
experimental/sgl-router/src/server/mod.rs 模块导出 modified 4.39
experimental/sgl-router/Cargo.toml 依赖清单 modified 3.38
experimental/sgl-router/tests/proxy/pd_protocol_binding.rs 协议绑定测试 modified 3.49
experimental/sgl-router/Cargo.lock 依赖锁 modified 2.21

关键符号

InflightHttp::new InflightHttp::count InflightHttp::enter InflightGuard::drop track_body TrackedBody::poll_frame TrackedBody::is_end_stream TrackedBody::size_hint count_requests AppContext::with_active_load wait_for_inflight_http inflight_http_counts_a_streaming_response_until_its_body_finishes inflight_http_returns_to_zero_after_non_proxied_routes

关键源码片段

experimental/sgl-router/src/server/inflight.rs core-logic

全新核心模块:定义 `InflightHttp`(`AtomicUsize` 诊断 gauge)、`InflightGuard`(Drop 释放,兼容请求被取消)、`TrackedBody` / `track_body`(把计数附着在响应体上,直到最后一帧或被 drop)。还内嵌 4 个单元测试锁定释放语义,是整条 drain 链路的度量地基。

// experimental/sgl-router/src/server/inflight.rs(新增)
// 计数对象:一个进程内原子计数器,外加 RAII 守卫与响应体包装器。
// 语义边界:这里数的是 axum 优雅关闭真正等待的量 —— 所有路由上仍然打开的
// HTTP 交换,而不是 ActiveLoadRegistry 只覆盖的「被代理请求」子集。
​
#[derive(Debug, Default)]
pub struct InflightHttp {
    open: AtomicUsize,
}
​
impl InflightHttp {
    pub fn new() -> Arc<Self> {
        Arc::new(Self::default())
    }
​
    /// 只读快照。Relaxed 足够:这是诊断量纲,读者与增量竞争时最多晚一个 tick
    /// 看到新值,绝不会读到撕裂值。
    pub fn count(&self) -> usize {
        self.open.load(Ordering::Relaxed)
    }
​
    /// 进入一次交换:计数 +1 并返回守卫,守卫 drop 时自动 -1。
    /// 用守卫而不是显式的 increment/decrement 对,是因为请求 future 可能在
    /// 响应构造完成前就被取消(客户端提前断开),那时没有地方调用 decrement。
    pub fn enter(self: &Arc<Self>) -> InflightGuard {
        self.open.fetch_add(1, Ordering::Relaxed);
        InflightGuard(Arc::clone(self))
    }
}
​
pub struct InflightGuard(Arc<InflightHttp>);
​
impl Drop for InflightGuard {
    fn drop(&mut self) {
        self.0.open.fetch_sub(1, Ordering::Relaxed);
    }
}
​
/// 本 PR 的关键设计:计数附着在响应体上,而不是 handler 的 future 上。
/// 流式补全(SSE)在 header 就绪时就返回 handler,正文还要继续写很久;
/// 若在 handler 返回处释放计数,整段 drain 等待窗口都会读出 0。
struct TrackedBody {
    inner: Body,
    _guard: InflightGuard, // 持有即计数,随 body 一起 drop
}
​
/// 包装响应体,让交换在「客户端还在被写入」的整个期间保持被计数。
pub fn track_body(body: Body, guard: InflightGuard) -> Body {
    Body::new(TrackedBody { inner: body, _guard: guard })
}
​
impl HttpBody for TrackedBody {
    type Data = Bytes;
    type Error = axum::Error;
​
    fn poll_frame(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
        // Body 与 InflightGuard 都是 Unpin,直接字段借用转发即可,
        // 不需要 pin-project,也没有任何数据拷贝。
        Pin::new(&mut self.get_mut().inner).poll_frame(cx)
    }
​
    // 以下两个方法纯透传,保证包装层对下游不可见。
    fn is_end_stream(&self) -> bool {
        self.inner.is_end_stream()
    }
​
    fn size_hint(&self) -> SizeHint {
        self.inner.size_hint()
    }
}
experimental/sgl-router/src/server/app.rs entrypoint

唯一接入点:`count_requests` 中间件在 `record_ingress` 后 `enter()` 计数,并把守卫通过 `resp.map(|body| track_body(body, inflight))` 搬进响应体,替换原来的直接 `resp` 返回。这一行是本 PR 全部语义的承载处,改动虽小但决定了计数是否覆盖流式响应。

// experimental/sgl-router/src/server/app.rs —— build_router 上的边缘中间件
// 它同时承担两类职责:原有的 requests_total / responses_total 入口 - 出口计数,
// 以及新增的 inflight_http 在途交换计数(唯一能看到「每条路由、每次交换」的位置)。
​
async fn count_requests(State(ctx): State<Arc<AppContext>>, req: Request, next: Next) -> Response {
    let method = req.method().as_str().to_owned();
    // route 取匹配到的模板而非原始 URI,避免标签基数爆炸。
    let route = req
        .extensions()
        .get::<MatchedPath>()
        .map(|m| m.as_str().to_owned())
        .unwrap_or_else(|| "unmatched".to_owned());
​
    // 原逻辑:入口侧指标 requests_total{route, method}
    ctx.metrics.record_ingress(&route, &method);
​
    // 新增:在任何 handler 之前入场计数,守卫先留在本函数局部变量里。
    let inflight = ctx.inflight_http.enter();
​
    let resp = next.run(req).await;
​
    // 原逻辑:出口侧指标 responses_total{route, method, status_code}
    ctx.metrics
        .record_response(&route, &method, resp.status().as_u16());
​
    // 决定性的一行:把守卫从中间件作用域搬到响应体上。
    // 本函数在此处返回时,流式补全的正文往往才刚开始写 —— 若在此 drop 守卫,
    // 计数会在整个 drain 等待窗口里错误地读出 0。
    resp.map(|body| crate::server::inflight::track_body(body, inflight))
}
experimental/sgl-router/src/server/app_context.rs entrypoint

把 `inflight_http: Arc<InflightHttp>` 注入共享上下文,注释明确它与 `active_load` 的职责差异(后者只覆盖被代理子集);在 `with_active_load` 与 `#[cfg(test)] stub()` 两处构造点都完成初始化,保证测试框架下计数可用。

// experimental/sgl-router/src/server/app_context.rs
// 新增字段:所有路由共享的进程内在途交换计数。
// 注释点明与 active_load 的区别 —— 后者只看得见被代理的那部分请求,
// 而这里覆盖非代理路由与流式正文仍在写入的窗口。
pub struct AppContext {
    // ...(tokenizers / proxy / registry / policies / metrics / engine_load 等原有字段)
    pub kv_metrics: Option<KvIndexMetrics>,
    /// Open HTTP exchanges, on every route. What axum's graceful shutdown
    /// waits on — `active_load` sees only the proxied subset.
    pub inflight_http: Arc<InflightHttp>,
    ready: AtomicBool,
}
​
// 生产路径:with_active_load 内构造,跟随 AppContext 生命周期。
// let metrics = MetricsRegistry::new();
// active_load.attach_metrics(Arc::clone(&metrics));
// self { /* ... */ inflight_http: InflightHttp::new(), ready: AtomicBool::new(false) }
​
// 测试路径:stub() 同样要初始化,否则基于 AppContext 的中间件测试无法计数。
// (本次同时把 ModelConfig 的 sampling_overrides 补进 stub,与 pd_protocol_binding
// 的 fixture 修复同源,都是 #39000 引入字段后的编译适配。)

评论区精华

计数应挂在响应体而不是 handler future 上 设计

PR body 与 inflight.rs 的模块注释共同论证:chat completion 的 handler 在 header 就绪时即返回,正文随后还要写数分钟;`ActiveLoadRegistry::inflight_count` 因为在 handler 出口释放而在这整段窗口读出 0,正是 drain 卡住的地方。作者按此把守卫交给 `TrackedBody` 持有。

结论:采纳原方案:`track_body` 让 `InflightGuard` 随 body 存活,中间件用 `resp.map(|body| track_body(body, inflight))` 完成搬运。 · 已解决

客户端中途断开不能让计数只增不减 正确性

作者在 PR body 中强调 "a client that disconnects mid-stream must not ratchet the gauge up for the life of the process":被放弃的 body 是被 drop 而非被排干,因此释放必须由 `Drop` 承担;`InflightGuard` 同时覆盖了请求 future 在响应构造前被取消的情形。单元测试 `an_abandoned_body_releases_the_count` 锁定该性质。

结论:以 RAII 守卫 + Drop 实现,配合专门的单元测试;`Relaxed` 原子序由 `count()` 的注释论证为足够。 · 已解决

测试非空性验证与 main 编译修复的夹带 测试

PR body 记录作者做过反向验证:「releasing the guard at handler exit instead of on body completion fails `inflight_http_counts_a_streaming_response_until_its_body_finishes` with `left: 0, right: 1`」,证明断言不是空跑。同时说明 `pd_protocol_binding.rs` 的一行修复确认是 #39006 fixture 与 #39000 字段同窗口落地的后果,与仪表无关但必须携带才能让本 PR 变绿。

结论:反向验证被接受;夹带修复被视为必要的解锁动作,未收到反对意见(reviewer sherlockwu 直接 APPROVED)。 · 已解决

风险与影响

  1. 暂无消费方,语义可能漂移:PR 自述 "No consumer yet",inflight_http 目前只是一个内部 AppContext 字段,既没有 /metrics 暴露,也没有被任何心跳读取。真正的语义(drain 用它判断「还剩多少)由 #39016 定义,届时若 drain 的实现与这里的释放时机假设不一致,本 PR 的正确性无法被现有测试完全锁定。
  2. 层序与 body 缓冲风险:守卫的释放时机取决于 TrackedBody 在整个 tower/axum layer 栈中的位置。Cargo.toml 里同时启用了 tower-http 的 compression-gzip、timeout、request-id 等层,如果某一层将响应体整体缓冲(而不是流式转发),计数窗口会被拉长到该层的生命周期,从而影响 drain 的时长判断。集成测试走的是真实 build_router 栈,但没有覆盖 gzip / timeout 组合下的在途观测,建议在 #39016 的 drain 测试中补一条。
  3. 全局热路径的额外包装:所有路由的每个响应都多了一层 http_body::Body 包装。poll_frame 只是字段借用转发(Body 与 InflightGuard 均 Unpin),无数据拷贝,单次成本极低,但属于每请求每帧都经过的路径,仍需在后续性能敏感变更中保持这一实现的轻量性。
  4. 计数口径包含非代理流量:/metrics、/readyz、/healthz、404 都会被计数。这是刻意的(它们同样是 axum drain 会等待的交换),但意味着一个被长期持有的 /metrics scrape 或慢客户端会延长未来的下线时间 —— 这是有意的权衡,而非缺陷,需要在 #39016 的文档里写清。
  5. 夹带修复的归因耦合:pd_protocol_binding.rs 的一行修复与仪表主题无关,混在同一 commit 中会让后续 bisect / blame 时看到不相关改动。风险很低(纯测试 fixture),但值得记录:这也暴露了多 PR 并行落地时 main 断编译的协作风险,需要靠 cargo test / clippy --all-targets 的常规绿灯来兜底。
  6. 兼容性:无 wire 协议变更、无响应内容变更、无配置项变更,对客户端完全透明;新增依赖 http-body 为 hyper 生态既有依赖,不引入版本冲突(Cargo.lock 只增一行)。

用户侧:零可见变化,PR body 明确 "This changes nothing on the wire and nothing in any response."。
系统侧:为 sgl-router 的优雅关闭提供唯一的「真实在途」数据源,使后续 drain 心跳能够解释「在等什么」;同时也第一次覆盖了非代理路由(/metrics、/readyz、404)上挂住的交换,这是 active_load 结构性看不见的盲区。
可观测性侧:目前仅存在于 AppContext,尚未导出为 Prometheus 指标,/metrics 上还看不到这条 gauge,接入方式由后续 PR 决定。
团队侧:这是 router 优雅关闭功能线的第 1/3 块,为 #39015(配置面)与 #39016(drain 本体 + k8s 证明)解除了前置依赖;同时解锁了 main 上 router 相关分支的 cargo test / cargo clippy --all-targets,影响所有从 main 切出的 router 分支。

暂无消费方(仪表先行) 全局边缘中间件热路径 依赖新增 http-body 夹带 main 编译修复 drain 语义待 #39016 锁定

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论