feat: add in-memory limiter and breaker satisfying backend contracts

This commit is contained in:
2026-07-20 06:54:07 -04:00
parent 454a8b5e0f
commit 4a176b6220
9 changed files with 645 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
"""进程内熔断门: CHS gate 契约的内存实现(D3 双后端)。
状态机蓝本 VT `adapters/breaker.py`(闭路→阈值开路→冷却半开→单探针),
契约形态承 CHS `provider_gate.py`: 半开探针是**带 TTL 的租约**(持有者
死亡后可被接管,防"探针永远在路上"死锁),写回经 epoch fencing 拒绝
旧世代污染。epoch 在每次进入 OPEN 时递增。时钟构造注入,纯确定性可测。
"""
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:
"""单源的门控可变状态。"""
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)
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())
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=g.open_until - now,
)
# 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=g.probe_expires - now,
)
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=max(0.0, g.open_until - self._now()) if g.state is GateState.OPEN else 0.0,
)
def _open(self, g: _SourceGate, reason: str) -> None:
g.state = GateState.OPEN
g.epoch += 1 # 世代推进: 旧 entry 的迟到写回自此被 fencing 拒绝
g.open_until = self._now() + self._cfg.cooldown_s
g.fails = max(g.fails, self._cfg.fail_threshold)
g.probe_owner = None
g.probe_expires = 0.0
async def record_success(self, entry: GateDecision) -> GateUpdate:
g = self._gate(entry.source_name)
if not self._fenced(g, entry):
return self._snapshot(g, applied=False)
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 entry.is_probe or force_open:
self._open(g, reason) # 探针失败重开 / SourceDead 一击即熔
return self._snapshot(g, applied=True)
g.fails += 1
if g.fails >= self._cfg.fail_threshold:
self._open(g, reason)
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 不能为空")
now = self._now()
waits = []
for name in sources:
g = self._gate(name)
if g.state is GateState.OPEN:
waits.append(max(0.0, g.open_until - now))
elif g.state is GateState.HALF_OPEN:
waits.append(max(0.0, g.probe_expires - now))
else:
waits.append(0.0)
return min(waits)