Files
PolyGateway/src/polygateway/backends/memory/breaker.py
T
iomgaa 8edd3fb2cd fix: pin retry_after_s to the next certain retry moment
retry_after_s never had a written definition, so each backend improvised
and they drifted apart. It now answers exactly one question: how long
until a retry is *certainly* worth attempting. OPEN has such a moment
(the cooldown deadline); HALF_OPEN does not, because the probe can come
back at any time -- so it reports 0.0, which already means "retry now"
elsewhere in the library.

Six exits are brought in line. The half-open rejection is the one issue
14 reported: it returned the probe lease remainder, a deadlock-guard
value derived from 2x the slowest timeout, so a 60s cooldown told
callers to wait 600s. Worse, retry.py fed that number into the source
cooldown memo, whose set_until only moves forward -- a source stayed
skipped in-process for the whole lease even after its probe succeeded
and the gate closed. That now writes an already-expired deadline, so
the memo goes back to recording only real OPEN cooldowns.

The other five were pre-existing memory/redis divergences hidden by a
contract-test blind spot (the suite pinned that a second caller gets
rejected, never what number it got): redis reported the probe TTL on
grant and the lease remainder on fenced-out writes, where memory has
always reported 0. Contract cases now pin all four half-open exits on
both backends, with 1:1 real-wait variants for redis since the
fake-clock ones skip there.
2026-08-20 00:09:20 -04:00

276 lines
11 KiB
Python
Raw 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.
"""进程内熔断门: 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)