Files
PolyGateway/research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md
T
iomgaa 0b3e84b3be docs: design the circuit-open wait policy for issue 14
The breaker conflates "this source is unhealthy" with "kill this call
now". Limiter rejections already choose between wait and fail_fast;
breaker rejections had no such choice, so a single-source scope loses
its whole retry budget the moment the gate opens.

Design adds {SCOPE}__CIRCUIT_OPEN (default fail_fast, so existing
deployments keep their control flow) and pins retry_after_s to "time
until a *certain* retry moment" across all six gate exits. The latter
also fixes a separate bug the issue missed: a half-open rejection fed
the probe lease (up to 2x timeout) into the source cooldown memo, whose
set_until only moves forward -- so a recovered source stayed blacklisted
in-process long after the gate closed. That one bites multi-source
deployments too, it is just hidden when other sources absorb the load.

Human-approved 2026-08-19; both documents revised after Codex review.
2026-08-19 23:45:57 -04:00

19 KiB
Raw Blame History

熔断拒绝补齐等待档: 把"源不健康"与"调用判死"解耦

  • issue: #14(dissect,单源第三方中转部署)
  • 核查基准: HEAD 1.2.3;issue 按 1.2.1 提交,逐条复核后全部仍然成立(backends/memory/breaker.py md5 630ed36ddeb87e08a9bac58260056046,1.0.6→1.2.3 逐字节未变)
  • 状态: 人类已确认(2026-08-19);经 Codex 审查修正(2026-08-19,修正点见 §3.1/§3.4/§3.5/§6 标注),待实施

1. 问题的真实形状

issue 把问题命名为"单源 scope 下熔断等于整体停服"。这个命名会把方案引向错误的方向——单源不是病因,是让病灶 100% 复现的放大器。三条独立缺陷叠加成了现场那 30 次瞬死,必须分开命名才修得干净。

1.1 缺陷一: 准入策略矩阵缺了一格

_pick_runnable 有四种"拒绝",库对它们的处置并不对称:

拒绝原因 计入 gate_rejections 全被拒时的处置 可配?
rate_limited(permit 拿不到) quota_full 分支 (wait/fail_fast)
adaptive_paced(AIMD 超限) quota_full 分支 (同上)
circuit_open(熔断门拒) 当场抛 CircuitOpenError
cooldown(源冷却备忘) 同上

限流闸满时库不判死、允许排队(quota_full=wait,缺省);熔断门拒时库只有 fail-fast 一档且不可配。两者在准入语义上完全同构(都不发请求、都带 retry_after 提示),处置却分叉。

这一格的缺失与源数量无关:多源全部同时开路(共同上游的中转挂了、一次全网抖动)时行为一模一样。单源只是把"全部开路"的概率从"罕见"变成"必然"。因此任何形态的单源特判(if len(sources) == 1)都是错的——它会让行为随池大小突变、无法组合测试,是比现状更重的债。

1.2 缺陷二: retry_after_s 在 HALF_OPEN 下返回了一个物理上无意义的数

try_enter 在 HALF_OPEN 拒绝时返回 probe_expires - now,即探针租约的剩余时长。而 probe_ttl_s 派生自 max(2 × 最慢源 timeout_s, cooldown_s, timeout_s + 5)(config.py:400-407),现场 TIMEOUT_S=300600 秒,而冷却期只有 60 秒。

探针租约的长度回答的是"探针最长可以占用这个名额多久"(死锁保护参数),与"这个源多久能恢复"没有任何因果关系。两个后端同款(backends/redis/breaker.pyTRY_ENTER/RETRY_AFTER 两个 Lua 均返回 probe_until - now)。

1.3 缺陷三(issue 未发现,伤害最重): 恢复了的源被本进程屏蔽整个探针租约

缺陷二的值被喂进了源冷却备忘:

retry.py:354   self._memo.set_until(cand.name, self._now() + entry.retry_after_s)
sources.py:139 self._until[name] = max(已有, until)      # 取更晚者,不可回退

于是:源 A 冷却到期 → 调用 1 拿到探针 → 并发的调用 2 被拒、拿到 600 → 给 A 记 600 秒本地冷却 → 调用 1 的探针成功、门恢复 CLOSED → 本进程此后 600 秒仍然跳过 A,且 reasons[A]="cooldown" 计入 gate_rejections,单源下每次调用照旧抛 CircuitOpenError

实测复现(InMemoryGate + 注入时钟,cooldown_s=60probe_ttl_s=600):

B 决定: allowed=False state=half_open retry_after_s=600.0   <- 冷却只有 60s
B 给 s1 记的本地冷却剩余: 600.0 秒
探针成功后门 state: closed
门已 CLOSED,memo.active('s1') = True
再过 120 秒(远超 60s 冷却)memo.active = True  剩余 480.0 秒

这条与源数量、与是否单源都无关:多源部署里,一个源每开路一次就会被本进程从池中除名 probe_ttl_s(可达 2 × timeout),池子越大越难被观测到,因为别的源接住了流量。现场那"30 次瞬死横跨 20 秒"里有多少来自这一条无法反推,但机制确凿。

2. 备选方案与否决理由

issue 给了 A/B/C/D 四条。逐条判:

方案 判定 理由
A PGW_BREAKER_BACKEND=noop 否决 关掉的是"保护"(401/403/配额耗尽的一击即熔一并失效,坏密钥持续撞墙),而诉求是"别当场判死"。且开了"治理组件可整个关掉"的先例,限流迟早跟进。三条缺陷一条都不解决
B {SCOPE}__CIRCUIT_OPEN=wait|fail_fast 采纳为主干 quota_full 严格同构,补的正是 §1.1 那一格。但 issue 版的 B 未答"wait 档等多久",而这个答案依赖 C
C 修 HALF_OPEN 的 retry_after_s 采纳,且不是"治标" issue 把它列为"可并行的小修"。实际上它是 B 的前提:wait 档要按 retry_after 睡,睡一个 600 秒的假数就是新事故。它还是 §1.3 的病根
D 只写文档 否决 把配置项的副作用固化成公开契约,将来动阈值逻辑即破坏;且解决不了 force_open

方案 = B + C,合并为一件事:B 依赖 C 的正确性,C 修完 §1.3 自动消失。

3. 设计

3.1 retry_after_s 的契约定死为"确定的最早可尝试时刻"

门状态 返回值 依据
CLOSED 0.0 现状,不变
OPEN open_until - now 现状,不变。冷却截止是确定时刻
HALF_OPEN(被拒) 0.0 探针随时可能出结果,不存在确定的等待时刻

0.0 不是新约定:errors.py 早已定义 retry_after_s0 = 可立即重试,契约测试 test_retry_after_semantics 也以"健康 → 0、冷却到期 → 0"钉着这个语义。HALF_OPEN 归入"无确定等待"是同一语义的自然延伸,而非发明。

信息不丢失:GateDecision.state 已经携带 HALF_OPEN,调用方要区分"门闭着"与"探针在途"照样能区分。

惊群由既有机制承担,不由这个数承担:门自身的单探针租约保证第二个 caller 拿不到名额;wait 档的复查间隔由 middleware 的 poll_interval_s 抖动睡眠承担(§3.3)。

§1.3 随之闭合:set_until(now + 0.0) 写入一个已过期的截止时刻,active() 恒 False——HALF_OPEN 拒绝自此不再污染备忘,无需在 retry.py 加任何状态分支。备忘回归它唯一正当的用途:记 OPEN 的确定冷却期

准入被允许时恒 0.0:allowed=True 意味着现在就能试,这个字段没有别的合理取值。

改动面是五个出口,不是两个(Codex 审查修正)。原稿只点了 try_enterretry_after_s(),漏了 GateUpdate 那一侧;逐一核实后发现两个后端在这两处本就已经分叉——本 issue 的病根正是"retry_after_s 语义从未被定死,于是各后端各自发挥",不一并收口就是定了新契约却留两个后端不遵守:

出口 memory 现状 redis 现状 统一为
try_enter 拒绝(OPEN) open_until - now 不变
try_enter 拒绝(HALF_OPEN) probe_expires - now probe_until - now 0.0
try_enter 授予探针 0.0(memory:114) probe_ttl_ms(redis:53) 0.0(redis 侧改)
GateUpdate(fencing 未命中,HALF_OPEN) 0.0(memory:175-177 非 OPEN 一律 0) probe_until - now(redis:127/158/258) 0.0(redis 侧三处改)
retry_after_s() 跨源取 min HALF_OPEN 记 probe_expires - now HALF_OPEN 记 0.0

后两行是既有缺陷,与本 issue 同源、由契约测试盲区掩护至今(现有用例只钉"第二个进入者被拒",没钉它拿到什么数)。同源缺陷一并修,不作为独立议题。

memory 侧抽 _remaining(g) 私有纯方法供三处共用;redis 侧四个 Lua(TRY_ENTER/RECORD_SUCCESS/RECORD_FAILURE/RELEASE_PROBE)与 RETRY_AFTER 各改一处(Lua 无法共享函数,这是既有约束,_WINDOW_HELPERS 已是同款处理),由同一批双后端参数化契约用例锁死。

3.2 新配置键 {SCOPE}__CIRCUIT_OPEN

quota_full 逐项对齐,不发明新形状:

维度 quota_full(既有) circuit_open(新增)
合法域 _QUOTA_FULL = {"wait","fail_fast"} _CIRCUIT_OPEN = {"wait","fail_fast"}
缺省 wait fail_fast(见 §3.5)
env 键 {SCOPE}__QUOTA_FULL {SCOPE}__CIRCUIT_OPEN
装配 settings → GatewayClient → 三条循环
校验 _validate_backends 表驱动 + 构造期 同(各加一行)

改动面: config.py(常量 / 字段 / 校验元组 / from_env 各一行)、client.py(签名 + 透传各一处)、SourceAdmission(§3.4)一处。

3.3 _on_no_runnable 的控制流

现状两个分支是串行的。今天走不到那个坑(没有 wait 档,第一分支必抛),但只要把第一分支改成"wait 时不抛"就会立刻踩中:控制流会往下掉进 quota_full 分支,quota_full=fail_fast 的调用方会看到熔断等待被误报成 reason="quota_exhausted"。必须改成按拒绝原因分派:

if gate_rejections == len(sources):        # 全部因熔断类原因被拒
    if circuit_open == "fail_fast": raise CircuitOpenError(retry_after=gate.retry_after_s(names))
    hint = await gate.retry_after_s(names)  # OPEN 有确定值;全 HALF_OPEN 得 0
else:                                       # 至少一源是被配额/AIMD 挡的
    if quota_full == "fail_fast": raise AllSourcesExhausted("quota_exhausted")
    hint = 0.0
if await self._stalled(clock): raise AllSourcesExhausted("stalled", ...)
await self._sleep(self._nap(hint, clock))

睡眠时长 _nap(hint, clock),三条约束同时满足:

约束 实现 理由
不空转 hint > 0 时睡到冷却结束再加抖动,而非 50ms 轮询 60 秒冷却下,poll_interval=0.05 会产生 1200 次无谓复查;memory 后端只是字典查询,redis 后端是 1200 次往返 × 每个在途调用
不白醒 抖动加(hint + poll_interval × (0.5+0.5×rng)),不缩放 对一个确定的截止时刻提前醒必然被再拒一次
等待有可解释上界 夹到剩余 stall 预算:min(睡眠, stall_window - clock.stalled_s()),下界 poll_interval 最迟在 stall 窗口耗尽那一刻醒来判死,单次调用最坏墙钟 = stall_window_s(缺省 300s),不随 max_cooldown_s 漂移

hint = 0 时该式退化为现有的 poll_interval × (0.5+0.5×rng),配额等待路径逐字不变。

计时归属无需改动:这段睡眠发生在 clock.attempting() 之外,自动计入 stall 账,与 ARCH §7.3 "熔断冷却属非生产性等待"的既定口径一致。

3.4 前置收敛: 准入逻辑三处复制归一

_pick_runnable / _on_no_runnable 目前在 middleware/retry.pyembedding.pyocr.py 各有一份,后两份是第一份的逐字子集(少 AIMD pacer 与调用内降权)。若只改 chat 一处,embedding/ocr 就成了行为分叉的角落——那才是本次真正会留下的技术债(CLAUDE.md 铁律痛斥的"三项目 4 处复制"的库内同款)。

middleware/admission.py::SourceAdmission,持有 sources/selector/QuotaGate/BreakerGate/memo/backpressure/两个策略键/时钟三件套,暴露 pick()on_no_runnable()。三条循环的差异用注入表达,不留分支:

差异 处理 行为等价性
调用内降权(仅 chat) attempt_failspick() 入参 embedding/ocr 传空 dict 时 _demote_call_failures 恒等返回原序(demoted 为空即 return ordered)
AIMD pacer(仅 chat) pacer: AdaptivePacer | None = None None 时跳过 admit/enter,无副作用
_settle_and_release 三份复制 提为 middleware/ 模块级 async 函数 chat/embedding 签名为 (permit, actual),OCR 为 (permit) 且体内恒 settle(0)(ocr.py:438,Codex 审查补)。OCR 侧改为传 0,逐字等价;唯一可见变化是 warning 文案由"OCR permit 结算/释放失败"归一

已逐字 diff 核实(embeddingocr 两份完全相同;chat 多出的只有上表三类)。另有两处不在抽取边界内、须原样保留:chat 主循环顶部额外的一次 _stalled 预判(retry.py:286),以及 OCR 的健康喂数——它们属于各自的主循环与 _attempt,本次一行不动。

这不是任务外重构:修复本来就必须落在这三处,"改三遍"与"抽一份改一遍"工作量相当而后者才符合 P7;且这是既有方向的延续——StallClockbackoff_delay 已按同一原则收敛为共享单元(ARCH §7.3)。边界严格限定在准入与无源可跑的处置,_attempt 一行不动(三者差异大: 流式 / 批 / 图)。

执行分两个提交:①纯重构,验收标准是全套件逐字绿、无行为变更;②在单一位置加语义。①先行以保回滚点。

3.5 缺省值取 fail_fast

quota_full 缺省 wait,但 circuit_open 不跟随,理由是变更方向的危险性不对称:

取值 对存量下游的影响
fail_fast(采纳) 控制流逐字不变(全源被熔断拒仍当场抛 CircuitOpenError)
wait 把所有人的最坏墙钟从毫秒抬到 stall_window_s,且是"快速失败 → 长时间挂起"这个最危险的方向

issue 的诉求本身也不是改默认值,而是表达能力——其 §2.3 的原话是"库对这两种情形用的是同一套默认值、且不允许调用方表达自己属于哪一种"。多源下 fail-fast 确实是对的(换源比等待快),单源下调用方显式配 wait 即可。README 与 wiki 需明写"单源 scope 建议配 wait"。

3.6 errors.py 的职责边界补写

issue 要求修订 GatewayUnavailableError 那句"业务侧 catch 本类做延期重投"——它读起来像在鼓励每个下游各写一份重试逻辑。改为明确边界:调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是任务级重试,语义与调用级重试不同。

这不是新决策,是把 ARCH §7.2 已经写明的"单层重试原则"补进 docstring。零代码风险。

"缺省档零感知"须诚实收窄(Codex 审查修正): 缺省档保证的是控制流不变,不是零可见变更。retry_after_s 的语义修正在缺省档下同样生效——全源 HALF_OPEN 时 CircuitOpenError.retry_after_s 由"探针租约剩余"变为 0.0,而它是公开字段(errors.py:118)。这正是本次记 1.3.0 而非补丁号、且 CHANGELOG 需"请先读这一条"待遇的原因。另需注意 GatewaySettings 全部字段均无默认值(既有风格),新增 circuit_open 沿用之,直接构造该类的调用方须补一个参数。

4. 行为矩阵

场景 fail_fast(缺省,= 现状) wait
单源 OPEN,冷却 60s 立即 CircuitOpenError(retry_after=剩余冷却) 睡到冷却结束(夹在 stall 预算内)→ 探针 → 成功即返回
单源 force_open(401/403) 立即失败 等 60 → 探针又 401 → 等 120 …… 直至 stall 判死(≤300s)。代价须进文档
多源部分开路 不变(有源可跑就不进这个分支) 不变
多源全部开路 立即失败 等最早恢复的那个源(retry_after_s 取 min)
全部 HALF_OPEN(探针在途) CircuitOpenError(retry_after=0),语义准确(随时可能好) poll_interval 抖动复查,秒级拿到探针结果
配额满 / AIMD 超限 quota_full 管,逐字不变 逐字不变

5. 测试策略

行为变更须"先失败后通过"(CLAUDE.md 测试结果门)。分三层:

契约层(tests/contracts/test_breaker_contract.py,双后端参数化自动覆盖 memory + redis): 按 §3.1 那张表逐个出口钉——HALF_OPEN 被拒、授予探针、GateUpdate fencing 未命中、retry_after_s() 探针在途,四处均须 == 0.0;OPEN 语义不变(现有 test_retry_after_semantics 保持绿)。现有用例只钉了"第二个进入者被拒",没钉它拿到什么数,正是这个盲区放过了两处双后端分叉。Redis 侧依赖时间快进的变体在契约层会 skip,须同步补 tests/integration/test_redis_governance_time.py 的真实等待变体(既有约定,不缩放时长)。

单元层(tests/unit/test_backpressure.py 邻域,注入时钟/睡眠/rng): §1.3 的回归钉子——探针成功后备忘不再屏蔽该源(直接由 §3.1 的复现脚本转化);circuit_open=wait 下全源开路不抛 CircuitOpenError 而按 retry_after 睡;wait + quota_full=fail_fast 组合下熔断等待被误报成 quota_exhausted(§3.3 那个坑的钉子);wait 档最坏墙钟 ≤ stall_window_s 且判死 reason 为 stalledper_source_reasonscircuit_open;fail_fast 缺省下全部现有用例逐字绿。

收敛层: §3.4 的重构提交以"三条循环现有测试全绿、零新增用例"为验收——有新增用例即说明行为被动了。

6. 非功能与已知取舍

维度 结论
取消穿透 _nap 的长睡眠是 await self._sleep(...),CancelledError 逐字穿透;无新增 finally 资源
后端往返 wait 档每个冷却周期约 1 次 gate 查询(vs. poll_interval 轮询的 1200 次),Redis 压力低于按现状实现的朴素 wait
遥测 不加列。wait 等待期不发请求,无 attempt 行可记;调用级总等待下游可自测。进入/退出等待各打一条 logger.info(scope、per-source reasons、预计等待),使"等了多久"可从日志还原
等待上界的精确值 _stalled 判据是 > 而非 >=(retry.py:368,Codex 审查补)。睡眠恰好夹到剩余预算时,醒来 stalled_s() 等于窗口而不大于,不判死。故 _nap 夹到 剩余预算 + poll_interval_s,一次到位;最坏墙钟精确表述为 stall_window_s + 一个 poll 间隔,不是"恰好 stall_window_s"
备忘的跨进程滞后 本进程记了 OPEN 冷却后,即便别的进程的探针已把共享门关回 CLOSED,本进程仍会跳到本地备忘自然过期(_pick_runnable 先查备忘再问门)。这是备忘"以本地记录换 Redis 往返"的固有代价,误差有界(≤ 一个 cooldown),既有性质、本次不改;备忘是进程内存,无持久化,故不存在滚动升级残留
无限等待 _stalled 是双条件合取,同 scope 其他调用仍在出餐时本调用不判死(ARCH §7.3 已承认的残余性质)。单源全开路时无人出餐,条件 B 必然成立,会判死;多源部分开路则走不到这个分支。文档沿用既有措辞:需要硬上限的调用方自行 asyncio.wait_for
未解决 force_open 在 wait 档下把坏密钥的失败从毫秒拖到 stall 窗口。有意不特判——库无法区分"密钥坏了"与"中转抖了",选 wait 即声明"宁可等也不当场死"

7. 文档与发布

ARCH §7.4 增补本次决策与三条缺陷的成因;§9 配置面登记新键;README 能力表与配置表;Gitea wiki 按 docs-convention.md §2 同步;CHANGELOG 记为 1.3.0(新增配置键 + retry_after_s 语义变更,后者对下游可见,需"请先读这一条"待遇)。

GateDecision 的字段与 ProviderGate 端口签名均不变,故不触碰迁移兼容约束(ARCH §5.1)。

8. 已定决策(人类,2026-08-19)

# 决策 随之固定的实施边界
1 缺省取 fail_fast(§3.5) 存量下游零感知;issue 提交方需自行加 {SCOPE}__CIRCUIT_OPEN=wait。README/wiki 必须明写"单源 scope 建议配 wait",否则这个开关等于不存在
2 §3.4 的三处收敛本次一并做 拆为独立前置提交,验收标准是"全套件绿 + 零新增用例";该提交即回滚点