Files
PolyGateway/research-wiki/designs/2026-08-06-issue8-stall-budget-design.md
T
iomgaa 573e505a4b docs: add the implementation plan for issue #8
Six tasks: StallClock plus the chat loop, then embedding, ocr, the config
comments, the full-suite regression with doc sync, and independent
verification. Codex review raised four points, all confirmed and folded in:
a stale line reference in the fidelity section, explicit cancellation
acceptance for T2/T3 (the new attempting() wrapper now wraps their existing
cancel paths), a telemetry-boundary test pinning the design's claim that
telemetry jitter must not feed the stall verdict, and concrete test
construction for the embedding/ocr regressions.
2026-08-06 09:09:31 -04:00

16 KiB
Raw Blame History

stall 判定改为非生产性等待口径设计(Issue #8)

  • 日期: 2026-08-06
  • 来源: Gitea Issue #8(本机全套件跑 391.67s,1 failed;失败源于单次 300s 超时耗尽 stall 窗口,基于 1.1.0 源码核查)
  • 状态: 已批准(2026-08-06),待 writing-plans
  • 触发档位: 强制(变更治理行为——判死条件的度量口径,是库对下游的承诺)
  • 方案范围: 人类明确要求单一方案,故本文不列平行备选,仅在 §5 记录被否决路线及否决理由(体例沿用 Issue #7 设计)

1. 目标与非目标

内容
G1 消除"单次超时即判 scope 级死亡"——timeout_sstall_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 观察到的"静默失效"就是这个重叠计费的直接后果。

.envTIMEOUT_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

"生产性"的边界即 _attempt 的边界——包含该次尝试的记账(record_success/mark_progress)与遥测收尾,而不止于"等响应"。这是有意的:这些收尾是"尝试已有结论"之后的动作,不是"在等待重试机会"的停滞;把它们计入 stall 会让遥测抖动参与判死,与「遥测写失败降级不冒泡」所守的"遥测不得影响主路径判决"同精神。其耗时本也在毫秒量级。

这与库内既有原则同构: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 记录该先例),不新建模块、不动依赖层次。

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

调用点改动(三处循环同款):

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 场景下不随轮次增长:429 免预算使 fails 恒为 0,retry.py:243max(fails, 1) 令退避恒定在 backoff_base_s 档(或取 Retry-After 提示的较大值)。但这不影响结论——每轮的构成是「一次快速失败的 429 往返(网关立即拒绝,不耗 timeout_s,毫秒至秒级)」+「一段恒定退避 sleep(backoff_base_s 量级)」,后者是非生产性且每轮都在累加。故饱和期内非生产性时间仍占绝对多数,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_stimeout_s 不再有任何耦合,这条校验没有存在的理由——不加校验、而是消除掉需要校验的耦合。

5.2 既有校验 stall_window_s ≥ max(ttft_timeout_s) 的处置

config.py:240-247_validate_stallARCHITECTURE.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:191ocr.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 配置可能需要相应调小。