Files
PolyGateway/research-wiki/designs/2026-08-06-issue8-stall-budget-design.md
T
iomgaa ce2dda7d45 docs: sharpen the productive-time boundary after Codex review
Two internal-consistency fixes from the independent design review:
the 429 saturation argument wrongly claimed exponential backoff growth
(429 skips the retry budget, so max(fails, 1) pins the delay to the base
tier), and "productive" was defined as waiting on the response while the
StallClock actually wraps all of _attempt. The boundary is now stated as
_attempt itself, including per-attempt accounting and telemetry, with the
rationale that telemetry jitter must not participate in the stall verdict.
2026-08-06 08:16:07 -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 源码核查)
  • 状态: 待人类批准
  • 触发档位: 强制(变更治理行为——判死条件的度量口径,是库对下游的承诺)
  • 方案范围: 人类明确要求单一方案,故本文不列平行备选,仅在 §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 配置可能需要相应调小。