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.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
# 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` 配置可能需要相应调小。
|
||||
Reference in New Issue
Block a user