Files
PolyGateway/research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md
iomgaa c5b2b3fade docs: correct how a wait-mode call actually dies on a dead source
Branch review caught the docs claiming something the code does not do.
CHANGELOG, README and the design's behaviour matrix all said a
force-opened source under circuit_open=wait waits out the full stall
window. It does not: the probe let through after each cooldown is a
real attempt, so it burns a max_attempts slot like any other, and a
401 source usually runs out of retry budget first -- reason is
retry_exhausted, not stalled. Which budget wins depends on
max_attempts against the cooldowns and the stall window.

The behaviour is right; only the prose was wrong. Charging the probe
to the retry budget is exactly the split issue #8 settled: the
question is who spends max_attempts, and a probe does send a real
request. A test now pins it so the claim cannot drift again.

Also drops the planned "woke up" log line. Each wait round already
logs on entry with its duration, and a still-blocked wake-up logs the
next round immediately, so a second line would only double the volume.
2026-08-20 01:00:47 -04:00

228 lines
20 KiB
Markdown
Raw Permalink 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.
# 熔断拒绝补齐等待档: 把"源不健康"与"调用判死"解耦
- **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=300`**600 秒**,而冷却期只有 60 秒。
探针租约的长度回答的是"探针最长可以占用这个名额多久"(死锁保护参数),与"这个源多久能恢复"没有任何因果关系。两个后端同款(`backends/redis/breaker.py``TRY_ENTER`/`RETRY_AFTER` 两个 Lua 均返回 `probe_until - now`)。
### 1.3 缺陷三(issue 未发现,伤害最重): 恢复了的源被本进程屏蔽整个探针租约
缺陷二的值被喂进了源冷却备忘:
```text
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=60``probe_ttl_s=600`):
```text
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_s``0 = 可立即重试`,契约测试 `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_enter``retry_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"`。必须改成按拒绝原因分派:
```text
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.py``embedding.py``ocr.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_fails``pick()` 入参 | 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 核实(`embedding``ocr` 两份**完全相同**;chat 多出的只有上表三类)。另有两处**不在抽取边界内**、须原样保留:chat 主循环顶部额外的一次 `_stalled` 预判(`retry.py:286`),以及 OCR 的健康喂数——它们属于各自的主循环与 `_attempt`,本次一行不动。
**这不是任务外重构**:修复本来就必须落在这三处,"改三遍"与"抽一份改一遍"工作量相当而后者才符合 P7;且这是既有方向的延续——`StallClock``backoff_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(**烧掉一格 `max_attempts`**)→ 等 120 → …… 以**先耗尽的那个预算**的 reason 失败: `max_attempts` 先尽则 `retry_exhausted`,冷却累计超过 stall 预算则 `stalled`。**代价须进文档** |
| 多源部分开路 | 不变(有源可跑就不进这个分支) | 不变 |
| 多源全部开路 | 立即失败 | 等最早恢复的那个源(`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 为 `stalled``per_source_reasons``circuit_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` 即声明"宁可等也不当场死" |
| 两个预算并行(整分支审查发现,2026-08-20) | `wait` **不豁免重试预算**: 冷却结束后放行的探针是一次真实尝试,失败照样烧一格 `max_attempts`(issue #8 的划分依据是"谁消耗重试预算",探针发出了真实请求,理应记在重试预算上)。故 force_open 的源常以 `retry_exhausted` 而非 `stalled` 结束。原稿 §4 只写了 stall 一种结局,已更正;由 `test_wait_does_not_exempt_probes_from_the_retry_budget` 钉住 |
## 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 的三处收敛**本次一并做** | 拆为独立前置提交,验收标准是"全套件绿 + 零新增用例";该提交即回滚点 |