Files
PolyGateway/research-wiki/designs/2026-08-06-issue8-stall-budget-design.md
T
iomgaa bfe423ddf8 docs: bill only non-productive waiting against the stall budget
Issue #8: a single request that burns its full timeout_s also exhausts
stall_window_s, so the retry budget silently never applies. Root cause is
that both budgets charge the same wall-clock time. The design makes the two
budgets orthogonal — real attempts bill the retry budget, everything else
bills the stall budget — which drops the timeout_s / stall_window_s coupling
instead of guarding it with an assembly-time check.
2026-08-06 08:01:07 -04:00

231 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# stall 判定改为非生产性等待口径设计(Issue #8)
- **日期**: 2026-08-06
- **来源**: Gitea Issue #8(本机全套件跑 391.67s,1 failed;失败源于单次 300s 超时耗尽 stall 窗口,基于 1.1.0 源码核查)
- **状态**: 待人类批准
- **触发档位**: 强制(变更治理行为——判死条件的度量口径,是库对下游的承诺)
- **方案范围**: 人类明确要求单一方案,故本文不列平行备选,仅在 §5 记录被否决路线及否决理由(体例沿用 Issue #7 设计)
## 1. 目标与非目标
| | 内容 |
|---|---|
| **G1** | 消除"单次超时即判 scope 级死亡"——`timeout_s``stall_window_s` 的隐式耦合彻底解除,重试预算在超时场景下真实可用 |
| **G2** | 使 stall 判定的度量对象与它的职责一致:**它治理的是无人治理的非生产性循环,不是已被重试预算治理的真实尝试** |
| **G3** | 三条治理循环(chat / embedding / ocr)口径一致,计时逻辑收敛为单一共享单元,杜绝第四次复制 |
| **G4** | 配置方不再需要心算 `stall_window > timeout × max_attempts`;`.env.example` 注释与实际语义对齐 |
| **非目标** | 不改 `progress_age_s()``inf` 语义(见 §3.4);不新增装配期校验(见 §5.2);不新增配置项;不改 `AllSourcesExhausted` 的字段与 `reason` 取值;不给 embedding/ocr 新增主循环判死路径(见 §5.4);不改 429 免预算、AIMD、选源、熔断任何既有行为 |
### 1.1 Issue 前提的三处修正(按 1.1.0 源码核实)
| Issue 原文 | 实际情况 |
|---|---|
| 失效点为 `retry.py:216` 一处 | **三处同构**:`retry.py:216`(主循环)、`retry.py:305` / `embedding.py:247` / `ocr.py:272`(`_on_no_runnable`)。四个判定点共用同一个墙钟 `entered_at`,故 embedding/ocr 在"先超时一次、再遇到无可用源"时同样误判——issue 只覆盖了 chat |
| 建议方向 1:装配期校验 `stall_window_s > max(timeout_s)` | **不采纳**。它把耦合固化成契约而非消除耦合,且约束值须为 `timeout × max_attempts`(本机即 900s),会让 stall 兜底迟钝到近乎失效。详见 §5.1 |
| 建议方向 2:`inf` 不参与判死 | **不采纳**。在新口径下 `inf` 从"有害恒真"变回"正确的保守默认";且它会反转已被测试钉住的既有行为。详见 §3.4 与 §5.3 |
## 2. 根因:两个预算重叠计费
`retry.py:214-215` 的注释自述这处判定是「429 免预算后的兜底,防饱和期无限循环」——它治理的对象是**非生产性循环**。但条件 A `now - entered_at > stall` 度量的是**墙钟总耗时**,无法区分两类性质相反的时间:
| 时间性质 | 构成 | 应由谁治理 | 耗尽后 |
|---|---|---|---|
| **生产性** | 真实发出请求并等待响应(含耗满 `timeout_s` 的超时、TTFT 等待、流式读取) | `max_attempts`(重试预算) | `retry_exhausted` |
| **非生产性** | 429 退避、配额 wait 轮询、熔断冷却轮询、AIMD 排队 | **无人治理**(429 不计 `fails`)→ 正是 stall 的职责 | `stalled` |
**缺陷即:生产性时间同时向两个预算计费。** 而 stall 预算(默认 300s)远小于重试预算(`3 × 300s`),必然先耗尽,于是重试预算在超时场景下**永远用不上**——issue 观察到的"静默失效"就是这个重叠计费的直接后果。
`.env``TIMEOUT_S=300``_DEFAULT_STALL_WINDOW_S=300.0`(`config.py:60`)相等只是把它暴露得最快;只要 `timeout_s ≥ stall_window_s / 1`,一次超时就够。
### 2.1 两条佐证:`inf` 恒真是遗漏而非设计
| 证据 | 出处 | 含义 |
|---|---|---|
| `_PROGRESS_TTL_S = 3600 # 远大于任何 stall_window,防进度键过期造成假停滞` | `backends/redis/limiter.py:32` | 「无 progress 记录 ≠ 停滞」早已是设计共识,作者用超长 TTL 规避了"键过期"这一路径,但 TTL 再长也救不了"**从来没写过**"——冷启动是同类情形的漏网之鱼 |
| `test_global_stale_but_local_fresh_keeps_waiting` docstring 写「仅全局超窗(从未出餐 age=inf)」 | `tests/unit/test_backpressure.py:120-121` | 现有测试把 `inf` 当作"全局超窗成立"钉住了;`test_both_windows_exceeded_raises_stalled`(:89)更是**全靠 `inf` 恒真**才能触发判死 |
## 3. 选定方案:双预算正交模型
### 3.1 一句话
**stall 计时器只累计非生产性等待时间**:`stalled_s = (now entered_at) 真实尝试累计耗时`
两个预算自此正交,各管一段,无缝覆盖调用的全部时间:
| 花在哪 | 烧哪个预算 |
|---|---|
| 真实尝试(`_attempt` 内) | 重试预算 `max_attempts` |
| 其余一切等待 | stall 预算 `stall_window_s` |
这与库内既有原则**同构**:429 不烧重试预算,所以 429 等待烧 stall 预算;真实尝试烧重试预算,所以它不烧 stall 预算。
### 3.2 为什么取补集,而不是逐处标记 sleep
两种实现都能达到 §3.1 的语义,选**取补集**(总时间减去 `_attempt` 耗时):
| 维度 | 取补集(选定) | 逐处标记 sleep(否决) |
|---|---|---|
| 埋点数量 | 每条循环 **1 处**(`_attempt` 调用点) | chat 3 处、embedding/ocr 各 2 处,共 7 处 |
| 演进安全性 | **默认安全**:将来新增任何等待路径自动计入 stall,兜底不会漏 | 默认危险:新增等待路径若忘记标记,即成新的 stall 盲区 |
| 语义可读性 | 「stall 时间 = 总时间 − 花在真实尝试上的时间」,一句话说清 | 需读者遍历全部标记点才能确认覆盖完整 |
`_attempt` 是纯生产性的:permit 获取、熔断准入、AIMD 判定全部在 `_pick_runnable` 内完成,`_attempt` 进入时已持 permit,内部只做"发请求 + 记账"。故补集口径不会把非生产性时间误算为生产性。
### 3.3 共享单元:`StallClock`
计时逻辑提取为 `middleware/retry.py` 的模块级小类,embedding/ocr 复用——沿用 `backoff_delay` 已被两者复用的既有手法(`tests/unit/test_backpressure.py:258` 记录该先例),不新建模块、不动依赖层次。
```python
class StallClock:
"""调用级 stall 计时器: 只累计非生产性等待(设计 §3.1)。
实例per调用创建, 严禁提升为实例属性——并发调用共享会互相污染。
"""
def __init__(self, now: Callable[[], float]) -> None:
self._now = now
self._entered_at = now()
self._productive_s = 0.0
def stalled_s(self) -> float:
return self._now() - self._entered_at - self._productive_s
@contextlib.asynccontextmanager
async def attempting(self):
started = self._now()
try:
yield
finally:
# 只做算术, 不吞任何异常——CancelledError 照常穿透(库铁律)
self._productive_s += self._now() - started
```
调用点改动(三处循环同款):
```python
clock = StallClock(self._now) # 替换 entered_at = self._now()
...
if clock.stalled_s() > stall and await self._quota.progress_age_s() > stall:
raise AllSourcesExhausted(..., reason="stalled", ...)
...
async with clock.attempting(): # 包裹真实尝试
outcome = await self._attempt(request, *picked, reasons, attempt_fails)
```
`_on_no_runnable` 的形参由 `entered_at: float` 改为 `clock: StallClock`(三处同改)。
### 3.4 `inf` 语义为何不动(本设计的核心权衡)
新口径下第一象限的含义变为:「**非生产性排队已耗满 `stall_window_s`,且整个 scope 从未出餐**」。此时判死是正当的——真的没有任何证据表明这个 scope 还活着,而调用方已经白等了一整个窗口。`inf` 由此从"有害的恒真"回归为"正确的保守默认"。
反过来,若同时改 `inf` 语义:
- 冷启动窗口内 stall 判定**完全失效**,429 饱和场景下 chat 主循环重新暴露无限循环风险(429 不计 `fails`,无其他兜底);
- 会反转 `test_both_windows_exceeded_raises_stalled` 钉住的行为,并与 CHS 保真蓝本分叉。
**一次改动解决问题,优于两次改动互相牵制。** 这是本设计只动条件 A 的理由。
### 3.5 429 饱和场景下兜底仍然有效(正确性验证)
修改后必须确认 stall 兜底没有被削弱:429 往返本身是生产性时间,不再计入 stall。但一次 429 往返是**快速失败**(网关立即拒绝,不耗 `timeout_s`),而其后的退避 sleep 是非生产性的且随尝试次数指数增长。故饱和期内非生产性时间占绝对多数,`stalled_s` 仍会在接近 `stall_window_s` 的时间内累满 —— 兜底有效,只是触发时刻比修改前晚了"若干次 429 往返"的量级(秒级),可忽略。
## 4. 旧版行为审计(stall 子系统逐条)
| 既有行为 | 处置 | 说明 |
|---|---|---|
| 双条件判死(本地超窗 ∧ 全局无进展超窗) | **保留** | 结构不变,只改条件 A 的度量口径 |
| 条件 A = 调用级累计、循环内不重置(CHS `governance.py:207`) | **保留** | `StallClock` 同样每调用一个实例、循环内不重置 |
| 条件 A 计入真实尝试耗时 | **替换** | 本设计的唯一行为变更 |
| 条件 B `progress_age_s()`,`inf` = 从未进展 | **保留** | 见 §3.4 |
| 本地 monotonic 与后端时钟刻意不混用 | **保留** | `StallClock` 只用注入的 `self._now`,不读后端时钟 |
| poll jitter ∈ [0.5p, 1.0p] 防惊群 | **保留** | 不触碰 |
| `fail_fast` 不进入 stall 判定 | **保留** | 不触碰 |
| 429 免预算(chat 独有) | **保留** | 不触碰;embedding/ocr 无此逻辑,故无对应缺口(§5.4) |
| `AllSourcesExhausted(reason="stalled")` 及其 `retry_after_s` 取值 | **保留** | 错误面零变更,下游 `except` 写法不受影响 |
**有意放弃**: 无。本设计不删除任何既有行为。
## 5. 被否决的路线
### 5.1 装配期校验 `stall_window_s > max(timeout_s)`(Issue 建议方向 1)
否决理由三条:
1. **治标**。它把"两个预算重叠计费"这个缺陷固化成一条配置契约,要求配置方绕开它,而不是消除它。
2. **约束值不可接受**。要让重试预算真正可用,须 `stall_window > timeout × max_attempts`(本机 900s)。stall 兜底随之迟钝到 900s 才触发,饱和期无限循环的防护近乎失效——**修好一个洞,挖开另一个**。
3. **挡不住残余情形**。即便配到 1200s,一次调用若在 429 轮询与超时上累计超过 1200s,条件 B 的 `inf` 仍恒真,双条件仍退化为单条件。坑只是被推远。
新口径下 `stall_window_s``timeout_s` 不再有任何耦合,**这条校验没有存在的理由**——不加校验、而是消除掉需要校验的耦合。
### 5.2 既有校验 `stall_window_s ≥ max(ttft_timeout_s)` 的处置
`config.py:240-247``_validate_stall``ARCHITECTURE.md` §7.3 记为契约补强 G6。新口径下 TTFT 等待属生产性时间,其 docstring 的理由「防把正常慢首包误判为卡死」**已不成立**。
**人类已定夺:保留校验,改写 docstring 说明新口径**。校验本身无害(不会误拒任何合理配置),保留可避免改动 ARCHITECTURE.md 既有契约、把本次改动的影响面控制在最小。docstring 改为说明"该校验在新口径下为保守冗余,TTFT 已不计入 stall"。
### 5.3 `inf` 不参与判死(Issue 建议方向 2)
见 §3.4:新口径下 `inf` 已无害,单独改它会制造冷启动兜底真空并反转既有测试。
### 5.4 给 embedding/ocr 补主循环 stall 判定
设计过程中一度提出(前提是"429 饱和时它们没有防无限循环兜底"),**核实后前提不成立,故否决**:
| 循环路径 | embedding/ocr 的兜底 |
|---|---|
| `picked is None``_on_no_runnable` 轮询(不烧 `fails`) | `_on_no_runnable` 内已有 stall 判定(`embedding.py:247` / `ocr.py:272`)✓ |
| 尝试失败 → `fails += 1` | `max_attempts` ✓ |
`retry.py:233-234` 的 429 免预算分支是 chat **独有**的(`embedding.py:191``ocr.py:216` 均为无条件 `fails += 1`,两文件亦无 `pacer`),主循环判定正是为它打的补丁。embedding/ocr 两条路径均已封闭,补齐等于凭空新增一条判死路径,使其比 chat 更易判死——纯 gold-plating。
## 6. 非功能维度
| 维度 | 回答 |
|---|---|
| **并发** | `StallClock` **每次调用创建一个实例**,是调用级局部状态,与被替换的 `entered_at` 局部变量同性质。严禁提升为实例属性(并发调用会互相污染计时)——docstring 已写明,单测钉住并发两路调用互不干扰 |
| **取消** | `attempting()``finally` 只做浮点加法,不含 `await`、不捕获任何异常,`CancelledError` 逐字穿透。既有 `test_cancellation_pierces_wait_loop` 继续有效,并新增一条"取消发生在 `_attempt` 内"的用例 |
| **降级方向** | 不变。stall 判定读取的 `progress_age_s()` 属准入侧,后端故障仍 fail-closed 抛 `GovernanceBackendError`(scope 级),不放行 |
| **幂等与重复** | `stalled_s()` 是纯读,可任意次调用;`attempting()` 可重入多次(每次尝试一次),累加语义天然幂等于"总生产性时间" |
| **持久化与原子性** | 不适用。纯进程内计时,无落盘、无后端写入,不新增任何 Redis 往返 |
| **性能** | 每次尝试新增两次 `self._now()` 调用与一次浮点加法,可忽略 |
## 7. 错误处理与测试策略
**错误分类**: 无变更。判死仍抛 `AllSourcesExhausted(reason="stalled")`,属 scope 级不可用(`GatewayUnavailableError` 家族),下游延期重投语义不变。
### 7.1 回归证据(先失败后通过)
核心用例 `test_single_timeout_does_not_exhaust_stall_budget`:`stall_window_s == timeout_s == 300`,第一次尝试推进 `FakeClock` 超过 300s 后抛 `TransientError`,第二次返回成功。
- **改前**:第二次尝试发出前即被判死,抛 `AllSourcesExhausted(reason="stalled")`**失败**
- **改后**:重试预算正常生效,返回成功响应 → **通过**
embedding / ocr 各一条同构用例(经"先超时一次、再遇到无可用源"触发 `_on_no_runnable`)。
### 7.2 其余用例
| 用例 | 钉住什么 |
|---|---|
| 四象限现有四条(`TestStallQuadrants`) | 非生产性路径行为逐字不变;`test_both_windows_exceeded` 全程无真实尝试,`stalled_s` 等价于旧墙钟,**应原样通过** |
| `test_productive_time_excluded_from_stall` | 直接断言:仅靠真实尝试耗时无论多久都不触发判死 |
| `test_nonproductive_wait_still_triggers_stall` | 反向:纯轮询等待累满窗口仍正常判死(兜底未被削弱) |
| `test_saturation_429_still_stalls` | §3.5 的正确性验证:429 连续拒绝 + 退避,最终仍判死而非无限循环 |
| `test_cancel_inside_attempt_pierces` | 取消穿透 `attempting()``finally` |
| `test_concurrent_calls_do_not_share_clock` | 两路并发调用,一路长尝试不影响另一路的 stall 账 |
Redis 后端无需新增用例:本设计不改后端接口与 `progress_age_s()` 语义。
## 8. 交付清单(供 `writing-plans` 展开)
| # | 内容 |
|---|---|
| T1 | `middleware/retry.py` 新增 `StallClock`;主循环与 `_on_no_runnable` 改用之 |
| T2 | `embedding.py` / `ocr.py` 复用 `StallClock`,`_on_no_runnable` 形参改签名 |
| T3 | `config.py:240-247` `_validate_stall` docstring 改写(§5.2) |
| T4 | 测试:§7.1 回归三条 + §7.2 五条;`test_backpressure.py:121` docstring 订正 |
| T5 | `.env.example:41` 注释改写(删除误导性的"须 ≥ 最大源 TTFT",说明新口径);本机 `.env:37` 的临时缓解 `STALL_WINDOW_S=1200` 可回退默认(不入库,仅记录) |
| T6 | `ARCHITECTURE.md` §7.3 背压条目补记新口径与本设计指针;`CHANGELOG.md` 记治理行为变更 |
| T7 | Wiki 同步(`docs-convention.md` §2「治理行为变更」行):`解释-治理行为` + `指南-限流与熔断` |
**副作用提醒**: 修复后单次调用最坏耗时由 `stall_window_s` 抬升至 `max_attempts × timeout_s`(本机 900s)——这是重试预算恢复生效的**正确表现**,但 e2e 冒烟测试的最坏耗时随之变长,`tests/e2e` 的源 `timeout_s` 配置可能需要相应调小。