"""进程内熔断门: CHS gate 契约的内存实现(D3 双后端)。 状态机蓝本 VT `adapters/breaker.py`(闭路→阈值开路→冷却半开→单探针), 契约形态承 CHS `provider_gate.py`: 半开探针是**带 TTL 的租约**(持有者 死亡后可被接管,防"探针永远在路上"死锁),写回经 epoch fencing 拒绝 旧世代污染。epoch 在每次进入 OPEN 时递增。时钟构造注入,纯确定性可测。 M2.5 双通道(设计 2026-07-21-m25 §3.1): 在连续失败通道之外加失败率 通道(双 30s 桶窗口,样本 ≥ min_calls 且失败率 ≥ fail_rate 即开路); 429(rate_limited)是背压不是故障,两通道均不计;开路时长按 reopen_streak 指数递增封顶 max_cooldown_s——连续通道触发的开路不递增 streak(误熔 健康源的代价封顶为单次 cooldown_s)。 """ from __future__ import annotations import time from dataclasses import dataclass, field from typing import TYPE_CHECKING from polygateway.ports import GateDecision, GateState, GateUpdate if TYPE_CHECKING: from collections.abc import Callable from polygateway.types import BreakerConfig @dataclass class _SourceGate: """单源的门控可变状态(窗口/退避域见设计 §3.1 状态机域清单)。""" state: GateState = GateState.CLOSED fails: int = 0 epoch: int = 0 open_until: float = 0.0 probe_owner: str | None = None probe_expires: float = 0.0 reasons: dict[str, str] = field(default_factory=dict) # 失败率窗口: 双半窗桶轮换(win_id = now // (window_s/2)) win_id: int = -1 a0: int = 0 # 当前桶 attempts / failures f0: int = 0 a1: int = 0 # 上一桶 f1: int = 0 reopen_streak: int = 0 closed_since: float | None = None class InMemoryGate: """按 source_name 分别计数的进程内熔断门。""" def __init__(self, *, config: BreakerConfig, now: Callable[[], float] = time.monotonic) -> None: self._cfg = config self._now = now self._gates: dict[str, _SourceGate] = {} def _gate(self, source_name: str) -> _SourceGate: if not source_name.strip(): raise ValueError("source_name 不能为空") return self._gates.setdefault(source_name, _SourceGate()) # —— M2.5 失败率窗口 —— def _rotate_window(self, g: _SourceGate) -> None: """按半窗粒度轮换双桶;跨两桶以上的空窗直接清零。""" half = self._cfg.window_s / 2.0 wid = int(self._now() // half) if wid == g.win_id: return if wid == g.win_id + 1: g.a1, g.f1 = g.a0, g.f0 else: g.a1, g.f1 = 0, 0 g.a0, g.f0 = 0, 0 g.win_id = wid def _window_add(self, g: _SourceGate, *, failed: bool) -> None: self._rotate_window(g) g.a0 += 1 if failed: g.f0 += 1 def _window_evidently_healthy(self, g: _SourceGate) -> bool: """窗口样本充足且失败率低于阈值 = 有充分健康证据(迭代 6 抑制判据)。""" self._rotate_window(g) attempts = g.a0 + g.a1 if attempts < self._cfg.min_calls: return False return (g.f0 + g.f1) / attempts < self._cfg.fail_rate def _rate_channel_open(self, g: _SourceGate) -> bool: self._rotate_window(g) attempts = g.a0 + g.a1 if attempts < self._cfg.min_calls: return False return (g.f0 + g.f1) / attempts >= self._cfg.fail_rate def _cooldown_eff(self, g: _SourceGate) -> float: streak = max(1, g.reopen_streak) return min(self._cfg.cooldown_s * (2 ** (streak - 1)), self._cfg.max_cooldown_s) def _remaining(self, g: _SourceGate) -> float: """距离**确定**可再试的时刻还有多久(issue #14 的契约定义)。 OPEN 的冷却截止是确定时刻;HALF_OPEN 下探针随时可能出结果,**不存在** 确定时刻,故 `0.0`——`0 = 可立即重试` 是库既有约定。此前这里返回探针 租约剩余,而租约长度是死锁保护参数(派生自 `2 × 最慢源 timeout`),与 "源多久能恢复"无因果关系;它还被喂进源冷却备忘,而备忘 `set_until` 取更晚者不可回退,于是门恢复 CLOSED 后本进程仍跳过该源整整一个租约。 三个出口(`try_enter` 拒绝、`_snapshot`、`retry_after_s`)共用本方法, 避免同一语义在三处各算一遍而漂移。 """ if g.state is GateState.OPEN: return max(0.0, g.open_until - self._now()) return 0.0 def _grant_probe(self, g: _SourceGate, source_name: str, owner: str) -> GateDecision: g.state = GateState.HALF_OPEN g.probe_owner = owner g.probe_expires = self._now() + self._cfg.probe_ttl_s return GateDecision( source_name=source_name, allowed=True, state=GateState.HALF_OPEN, epoch=g.epoch, is_probe=True, probe_owner=owner, retry_after_s=0.0, ) async def try_enter(self, source_name: str, owner: str) -> GateDecision: """健康普通准入;冷却到期/探针租约过期时原子授予唯一探针。""" if not owner.strip(): raise ValueError("owner 不能为空") g = self._gate(source_name) now = self._now() if g.state is GateState.CLOSED: return GateDecision( source_name=source_name, allowed=True, state=GateState.CLOSED, epoch=g.epoch, is_probe=False, probe_owner=None, retry_after_s=0.0, ) if g.state is GateState.OPEN: if now >= g.open_until: return self._grant_probe(g, source_name, owner) return GateDecision( source_name=source_name, allowed=False, state=GateState.OPEN, epoch=g.epoch, is_probe=False, probe_owner=None, retry_after_s=self._remaining(g), ) # HALF_OPEN: 探针在途;租约过期则接管,否则拒绝(防惊群) if now >= g.probe_expires: return self._grant_probe(g, source_name, owner) return GateDecision( source_name=source_name, allowed=False, state=GateState.HALF_OPEN, epoch=g.epoch, is_probe=False, probe_owner=None, retry_after_s=self._remaining(g), ) def _fenced(self, g: _SourceGate, entry: GateDecision) -> bool: """写回资格: 世代一致;探针写回还要求 owner 仍在位(CHS fencing 同款)。""" if not entry.allowed: raise ValueError("被拒决定不得写回") if entry.epoch != g.epoch: return False return not ( entry.is_probe and (g.state is not GateState.HALF_OPEN or g.probe_owner != entry.probe_owner) ) def _snapshot(self, g: _SourceGate, applied: bool) -> GateUpdate: return GateUpdate( applied=applied, state=g.state, epoch=g.epoch, failure_count=g.fails, retry_after_s=self._remaining(g), ) def _open(self, g: _SourceGate, reason: str, *, bump_streak: bool) -> None: if bump_streak: g.reopen_streak += 1 g.state = GateState.OPEN g.epoch += 1 # 世代推进: 旧 entry 的迟到写回自此被 fencing 拒绝 g.open_until = self._now() + self._cooldown_eff(g) g.fails = max(g.fails, self._cfg.fail_threshold) g.probe_owner = None g.probe_expires = 0.0 g.closed_since = None async def record_success( self, entry: GateDecision, *, count_attempt: bool = True ) -> GateUpdate: g = self._gate(entry.source_name) if not self._fenced(g, entry): return self._snapshot(g, applied=False) was_probe = entry.is_probe if count_attempt: self._window_add(g, failed=False) # streak 衰减: CLOSED 稳定满 2×cooldown_eff 后的首次成功归零(设计 §3.1) if ( g.reopen_streak > 0 and g.closed_since is not None and self._now() - g.closed_since >= 2 * self._cooldown_eff(g) ): g.reopen_streak = 0 if was_probe: g.closed_since = self._now() # 仅探针转 CLOSED 时写,普通成功不刷新 g.state = GateState.CLOSED g.fails = 0 g.probe_owner = None g.probe_expires = 0.0 return self._snapshot(g, applied=True) async def record_failure( self, entry: GateDecision, reason: str, force_open: bool ) -> GateUpdate: g = self._gate(entry.source_name) if not self._fenced(g, entry): return self._snapshot(g, applied=False) if reason == "rate_limited" and not force_open: # 429 = 背压不是故障(设计 §3.1): 两通道均不计,选源层软处理; # 探针撞 429 按无果归还语义放下家,不挂租约 if entry.is_probe: g.state = GateState.OPEN g.open_until = self._now() g.probe_owner = None g.probe_expires = 0.0 return self._snapshot(g, applied=True) if entry.is_probe or force_open: # 探针失败重开递增 streak;SourceDead 一击即熔不递增 self._open(g, reason, bump_streak=entry.is_probe) return self._snapshot(g, applied=True) self._window_add(g, failed=True) g.fails += 1 if self._rate_channel_open(g): self._open(g, reason, bump_streak=True) elif g.fails >= self._cfg.fail_threshold and not self._window_evidently_healthy(g): # 连续通道不递增(C1 封顶);窗口证据充足且健康时连败是噪声, # 不误熔"当前最好的源"(迭代 6,第八轮实证: 源1 被 5 连空补全误关 60s) self._open(g, reason, bump_streak=False) return self._snapshot(g, applied=True) async def release_probe(self, entry: GateDecision) -> GateUpdate: """探针无果归还(如取消): 源保持可接管状态让下一 caller 接手;幂等。""" if not (entry.allowed and entry.is_probe and entry.probe_owner is not None): raise ValueError("release_probe 只接受在途探针决定") g = self._gate(entry.source_name) if not self._fenced(g, entry): return self._snapshot(g, applied=False) g.state = GateState.OPEN g.open_until = self._now() # 立即可被下一 caller 以探针身份接管 g.probe_owner = None g.probe_expires = 0.0 return self._snapshot(g, applied=True) async def retry_after_s(self, sources: tuple[str, ...]) -> float: """集合中最早可尝试时间;健康/到期返回 0。""" if not sources: raise ValueError("sources 不能为空") return min(self._remaining(self._gate(name)) for name in sources)