80 lines
10 KiB
Markdown
80 lines
10 KiB
Markdown
# M2.5 治理韧性实现计划
|
||
|
||
- **目标**: 按已批准设计 `designs/2026-07-21-m25-resilience-design.md`(方案 B 三件套)实现半死源隔离与健康感知调度,P6 原场景成功率 ≥98%。
|
||
- **方案概述**: 熔断加失败率通道(双 30s 桶,429 不入)+ 开路指数退避;新增 HealthAwareSelector(EWMA×在途,0.05 地板探索)设为缺省;RetryMW 调用内失败降权。全部进程本地或并入既有 Redis gate HASH,零新依赖。**端口面变更两处(如实声明)**: ports.py 新增 OutcomeAwareSelector;`ProviderGate.record_success` 增关键字参数 `count_attempt: bool = True`——波及点全清单: ports.py:155、backends/memory/breaker.py:134、backends/redis/breaker.py:280、middleware/breaker.py:28(BreakerGate 包装)、middleware/retry.py 三处(:244 真实成功 True、:255 ResultInvalid False、:281 健康拒绝 False)、**embedding.py 两处**(:272 真实成功 True、:306 `_gate_on_terminal` ResultInvalid/健康拒绝 False)。`record_failure` 现签名已含 reason 透传(middleware/breaker.py:36),**无需改动**。
|
||
- **分支**: `feature/m25-resilience`(已建,设计已提交)。执行者须先通读设计文档全文——本计划不重复设计论证,只给任务分解;**冲突时以设计文档为准**。
|
||
- **保真校验**: 本计划触及 M2 移植的熔断 Lua(蓝本 CHS scripts.py)。铁则: §T2 只允许**新增**域与判据分支,五操作的既有语义(epoch 仅开断+1、探针租约、release_probe 置 open_until=now、fencing 判据)逐字不动;每个 Lua 改动完成后逐段比对 M2 版本,确认既有行为无漂移(契约旧用例全绿即证据)。
|
||
|
||
## 文件结构(改动全景)
|
||
|
||
| 文件 | 动作 |
|
||
|---|---|
|
||
| `src/polygateway/types.py` | BreakerConfig 增 4 字段(frozen 只增): `min_calls:int=10` `fail_rate:float=0.6` `window_s:float=60.0` `max_cooldown_s:float` |
|
||
| `src/polygateway/ports.py` | 新增 `@runtime_checkable OutcomeAwareSelector` Protocol(仅 `record_outcome(source_name:str, ok:bool)->None`) |
|
||
| `src/polygateway/config.py` | `_load_breaker`: 全局并发抬升改源级;读 4 个新可选键 `{SCOPE}__BREAKER__{MIN_CALLS,FAIL_RATE,WINDOW_S,MAX_COOLDOWN_S}`(缺省 10/0.6/60/max(300,cooldown));`_SELECTORS` 增 `health_aware` 且缺省值改之 |
|
||
| `src/polygateway/sources.py` | 新增 `HealthAwareSelector`(EWMA α=0.2 初始 1.0、score=max(ewma,0.05)/(1+inflight)、P2C 头名、rng 注入) |
|
||
| `src/polygateway/backends/memory/breaker.py` | 双桶窗口 + 率通道 + reopen_streak/closed_since + 429 旁路 |
|
||
| `src/polygateway/backends/redis/breaker.py` | 同上,Lua 内实现(gate HASH 固定域 win_id/a0/f0/a1/f1) |
|
||
| `src/polygateway/middleware/breaker.py` | BreakerGate.record_failure 透传 reason(现签名若已含 reason 则不动,核对后定) |
|
||
| `src/polygateway/middleware/retry.py` | attempt_fails 局部变量传参、降权重排、record_outcome 喂数(isinstance 缓存) |
|
||
| `src/polygateway/client.py` | 装配 health_aware 缺省 + selector 注入 rng |
|
||
| `.env.example` | 阈值抬升注释改源级;新增 4 键注释与 SELECTOR=health_aware |
|
||
| `research-wiki/migrations/chsanalyzer.md` | 标注: retry_after 上限 300s(arq defer 放大)、双通道偏离、缺省选源变更 |
|
||
| `src/polygateway/embedding.py` | record_success 两调用点补 count_attempt(:272 True、:306 False);embedding 独立选源循环**不装配 health_aware、不喂 record_outcome**(其源池独立且流量小,沿用既有选源;设计范围仅 chat 主循环——口径写死于此) |
|
||
| 测试 | 契约测试 = `tests/contracts/test_breaker_contract.py`(conftest `backend` fixture 参数化 memory+redis,redis 时间用例 SkipClock 跳过、由 `tests/integration/test_redis_governance_time.py` 真实等待变体补齐——新时间用例照此双轨) |
|
||
|
||
## 任务清单(每任务 = 一次提交,先红后绿)
|
||
|
||
### T1 类型与配置层
|
||
- [ ] `types.py` BreakerConfig 增 4 字段;`config.py` `_load_breaker` 改源级抬升 + 4 新键(缺省注释写明依据"韧性参数缺省先例");`_SELECTORS` 增 health_aware 并设缺省。
|
||
- 测试(先红): `tests/unit/test_config.py`(实际文件名以 grep 为准)——① 无新键时缺省 10/0.6/60/max(300,cooldown);② 源级抬升: 源 max_concurrency=8 → 阈值 max(配置,16),**无源级并发时不抬升**(全局并发 100 不再影响);③ SELECTOR 缺省 health_aware、显式 round_robin 仍可选。
|
||
- 验证: `conda run -n PolyGateway pytest tests/unit/test_config*.py -q` PASS。
|
||
|
||
### T2 熔断双后端(核心,契约驱动)
|
||
- [ ] 先在 `tests/contracts/test_breaker_contract.py` 加设计 §5 七组用例(①高失败率序列 min_calls 处开路 ②样本<min_calls 不率开 ③连续阈值=配置且源级抬升 ④退避 2^n 封顶+衰减 ⑤ResultInvalid/健康拒绝 attempts/failures 均不入 ⑥429 脉冲不开路、timeout 脉冲开路且不增 streak ⑦率开路 failure_count 顶格;全部注入确定性序列)→ 两后端同时红;时间推进类用例同步落 `tests/integration/test_redis_governance_time.py` 真实等待变体(用小 MAX_COOLDOWN_S 配置控时长)。
|
||
- [ ] memory/breaker.py: 状态机域清单表(设计 §3.1)逐域实现;429(reason=="rate_limited")两通道完全旁路;`record_success(entry, *, count_attempt=True)`——True 时窗口 attempts+1,False(ResultInvalid/健康拒绝)完全不动窗口。
|
||
- [ ] redis/breaker.py Lua: RECORD_FAILURE 增窗口固定域轮换(win_id/a0/f0/a1/f1)+率判据+streak;RECORD_SUCCESS 增 attempts 计数与 streak 衰减(closed_since 比较);开路分支统一算 cooldown_eff。**保真注意**: 现有 RECORD_SUCCESS 用 HSET 固定域列表(不删未列域),风险在显式清零清错域——只清 `failures`,新域按域表管理;逐段对照 M2 版本。
|
||
- 验证: `conda run -n PolyGateway pytest tests/contracts -q` 全绿(memory+redis 双参数,旧用例零破坏=保真门)+ `pytest -m slow tests/integration/test_redis_governance_time.py -q` 绿。
|
||
|
||
### T3 健康选源器
|
||
- [ ] `ports.py` OutcomeAwareSelector;`sources.py` HealthAwareSelector(rng 注入构造参数,P2C 用 rng 取两索引);`client.py` 装配: `_build_selector`(:275)增 rng 形参、`from_settings`(:204)把 GatewayClient 既有 `rng`(:81)传入;构造函数全量注入路径由调用方自备 selector,不变。
|
||
- 测试(先红): 新文件 `tests/unit/test_health_selector.py`——① 全 1.0 初始时近似均匀(注入定值 rng 断言确定性结果);② 喂 10 次失败后 score 地板 0.05 仍偶被选(rng 定向构造);③ 恢复源连续成功 EWMA 爬升(0.2→…);④ inflight 抑制;⑤ 非 OutcomeAware 选源器在 RetryMW 中零调用(isinstance 分支)。
|
||
- 验证: `pytest tests/unit/test_health_selector.py -q` PASS。
|
||
|
||
### T4 RetryMW 编排
|
||
- [ ] attempt_fails 局部变量 + `_pick_runnable(reasons, attempt_fails)` 降权重排(≥2 次失败移尾,全失败回退原序);`_attempt` 出口喂 record_outcome(真实成功 ok=True;Transient/SourceDead/429 ok=False;ResultInvalid/健康拒绝不喂);ResultInvalid/健康拒绝处 record_success 传 count_attempt=False;embedding.py 两调用点同步(文件表口径)。**record_outcome 调用必须 try/except 吞并 warning**(设计 §4: 选源器异常不得打断真实成功返回路径),含对应单测。
|
||
- 测试(先红): 既有 `tests/unit/test_retry.py` 追加——① 单失败源仍首选、双失败移尾;② 并发两调用互不串 attempt_fails;③ record_outcome 喂数矩阵(五出口各断言)+ 抛异常不影响返回;④ reasons/异常语义回归(旧用例全绿即可)。
|
||
- 验证: `pytest tests/unit -k retry -q` PASS;`make ci` 全绿。
|
||
|
||
### T5 文档与迁移标注
|
||
- [ ] `.env.example`(阈值注释改源级 + 4 新键 + SELECTOR)、`migrations/chsanalyzer.md` 标注四项: ① retry_after 上限 300s(G1 arq defer 放大)② 缺省选源变更 ③ 全局抬升废除改源级 ④ 双通道偏离;ARCHITECTURE.md 熔断节补双通道一段 + "治理粒度=配额粒度"原则一句(设计 §3.4)。
|
||
- 验证: `make lint` 绿;grep 确认 .env.example 无旧"并发×2"全局表述。
|
||
|
||
### T6 验收: P6 原场景回归(硬门)
|
||
- [ ] `make ci` 全绿后,以与首跑**逐字节相同**的命令重跑 P6(tmux,`--run-id` 留默认新 id): `PGW_PRICING_PATH=config/prices.json PGW_LIMITER_BACKEND=redis PGW_BREAKER_BACKEND=redis PGW_CACHE_BACKEND=redis PGW_CACHE_NAMESPACE=soak PGW_CACHE_TTL_S=21600 conda run -n PolyGateway --no-capture-output python -u tools/soak/run_soak.py --scenario P6 --budget-calls 8000 --budget-tokens 200000000 --workers 2 --concurrency 32 --scope SOAK --max-hours 3`。**.env 一字不改**。
|
||
- [ ] 缓存命中会抬高表观成功率——验收口径按**总成功率 ≥98%**(用户原话口径),同时报告"排除缓存命中后的真实调用成功率"作诚实附注;八不变量全 PASS;对照首跑出对比表(成功率/尝试流向/源5 吸量/429 量/成本)。
|
||
- [ ] 不达标: 按遥测数据定位短板(预期迭代旋钮: fail_rate、EWMA α、地板值、min_calls),每轮改动过契约测试后重跑;用户已授权多轮。
|
||
- [ ] 达标后: findings 记 M2.5 验收 + ROADMAP 补行 + 独立 verifier(全新上下文)核验全分支 → 清零后合并 main(用户已豁免过目)。
|
||
|
||
## 关键接口(跨任务消费,写死于此)
|
||
|
||
```python
|
||
# ports.py 新增(完整)
|
||
@runtime_checkable
|
||
class OutcomeAwareSelector(Protocol):
|
||
def record_outcome(self, source_name: str, ok: bool) -> None: ...
|
||
|
||
# ProviderGate.record_success 签名演进(唯一端口触碰,关键字参数带默认值)
|
||
async def record_success(self, entry: GateDecision, *, count_attempt: bool = True) -> GateUpdate: ...
|
||
|
||
# HealthAwareSelector 构造
|
||
def __init__(self, *, rng: Callable[[], float] = random.random) -> None: ...
|
||
```
|
||
|
||
BreakerConfig 新字段名: `min_calls` `fail_rate` `window_s` `max_cooldown_s`(env 键 `{SCOPE}__BREAKER__MIN_CALLS` 等四个)。gate HASH 新域名: `win_id a0 f0 a1 f1 reopen_streak closed_since`。
|
||
|
||
## 风险与回滚
|
||
|
||
- 每任务独立提交,T2 是最大风险面(Lua)——契约旧用例全绿是保真门,任何旧用例破坏立即停下按 systematic-debugging 根因。
|
||
- P6 重跑约 1-2 小时、成本约 25 元/轮(已有价格表),预算帽/守卫机制原样生效。
|