238 lines
8.4 KiB
Python
238 lines
8.4 KiB
Python
"""Redis 治理后端时间语义 1:1 真实等待变体(M2 设计 §2.3,人类拍板不缩放)。
|
|
|
|
契约文件中依赖 `clock.advance` 的用例在 redis 参数下被哨兵时钟 skip,
|
|
每个都在本文件有同名行为的 `test_variant_<原名去 test_>` 变体:配置用
|
|
真实量级(cooldown 60s / probe_ttl 120s / lease 30s),等待是真实的
|
|
`asyncio.sleep`。整文件约 13 分钟,标 slow(pytest addopts 默认排除,
|
|
显式 `pytest -m slow` 运行)。meta 用例(非 slow)解析契约源码保证
|
|
skip 集合与变体集合恒等,防契约新增时间用例而变体漏配。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import asyncio
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from polygateway.backends.redis.breaker import RedisGate
|
|
from polygateway.backends.redis.limiter import RedisLimiter
|
|
from polygateway.ports import GateState
|
|
from polygateway.types import BreakerConfig, GlobalLimits
|
|
from tests.contracts.conftest import (
|
|
await_window_headroom,
|
|
make_source,
|
|
redis_url_from_env,
|
|
)
|
|
|
|
# 与契约 _CFG 同值——真实量级,不缩放(2026-07-20 人类拍板)
|
|
_CFG = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
|
|
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
|
|
_CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts"
|
|
|
|
|
|
def _advance_dependent_cases() -> set[str]:
|
|
"""解析契约源码,提取调用 clock.advance 的测试函数名(去 test_ 前缀)。"""
|
|
found: set[str] = set()
|
|
for path in sorted(_CONTRACT_DIR.glob("test_*_contract.py")):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.AsyncFunctionDef | ast.FunctionDef):
|
|
continue
|
|
if not node.name.startswith("test_"):
|
|
continue
|
|
calls = [
|
|
sub
|
|
for sub in ast.walk(node)
|
|
if isinstance(sub, ast.Attribute) and sub.attr == "advance"
|
|
]
|
|
if calls:
|
|
found.add(node.name.removeprefix("test_"))
|
|
return found
|
|
|
|
|
|
def test_meta_variants_cover_all_time_cases():
|
|
"""完整性守卫: 契约的时间用例集合 == 本文件变体集合(1:1 映射表机械化)。"""
|
|
variants = {
|
|
name.removeprefix("test_variant_") for name in globals() if name.startswith("test_variant_")
|
|
}
|
|
assert variants == _advance_dependent_cases()
|
|
|
|
|
|
pytestmark_slow = pytest.mark.slow
|
|
|
|
|
|
@pytest.fixture
|
|
async def redis_client():
|
|
url = redis_url_from_env()
|
|
if url is None:
|
|
pytest.skip("REDIS_URL 未配置")
|
|
import redis.asyncio as aioredis
|
|
|
|
client = aioredis.from_url(url)
|
|
try:
|
|
yield client
|
|
finally:
|
|
await client.aclose()
|
|
|
|
|
|
def _limiter(redis_client, sources, *, lease_ttl_s: float = 30.0) -> RedisLimiter:
|
|
return RedisLimiter(
|
|
scope=f"t{uuid4().hex[:8]}",
|
|
sources={s.name: s for s in sources},
|
|
global_limits=_NO_GLOBAL,
|
|
redis=redis_client,
|
|
lease_ttl_s=lease_ttl_s,
|
|
)
|
|
|
|
|
|
def _gate(redis_client) -> RedisGate:
|
|
return RedisGate(config=_CFG, redis=redis_client, scope=f"t{uuid4().hex[:8]}")
|
|
|
|
|
|
async def _open_gate(gate: RedisGate, source: str = "s1"):
|
|
"""连续失败到阈值打开熔断(契约 _open_gate 同款)。"""
|
|
update = None
|
|
for _ in range(_CFG.fail_threshold):
|
|
entry = await gate.try_enter(source, "w")
|
|
assert entry.allowed
|
|
update = await gate.record_failure(entry, "network_error", False)
|
|
assert update.state is GateState.OPEN
|
|
return update
|
|
|
|
|
|
# —— 限流侧 2 例 ——
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_lease_expiry_reclaims_slot(redis_client):
|
|
"""契约 test_lease_expiry_reclaims_slot: 泄漏 permit 30s 后槽位回收。"""
|
|
limiter = _limiter(redis_client, [make_source(max_concurrency=1)])
|
|
_leaked = await limiter.try_acquire("s1", 0)
|
|
assert _leaked is not None
|
|
assert await limiter.try_acquire("s1", 0) is None
|
|
await asyncio.sleep(31.0)
|
|
reclaimed = await limiter.try_acquire("s1", 0)
|
|
assert reclaimed is not None
|
|
await reclaimed.release()
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_progress_marks_fresh(redis_client):
|
|
"""契约 test_progress_marks_fresh: inf → mark → age 随真实时间推进。"""
|
|
limiter = _limiter(redis_client, [make_source()])
|
|
assert await limiter.progress_age_s() == float("inf")
|
|
await limiter.mark_progress()
|
|
assert await limiter.progress_age_s() < 5.0
|
|
await asyncio.sleep(42.0) # 精确 42s: 断言含上界 43
|
|
assert 41.0 < await limiter.progress_age_s() < 43.0
|
|
|
|
|
|
# —— 熔断侧 7 例 ——
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_cooldown_grants_single_probe(redis_client):
|
|
gate = _gate(redis_client)
|
|
await _open_gate(gate)
|
|
await asyncio.sleep(_CFG.cooldown_s + 1)
|
|
probe = await gate.try_enter("s1", "w1")
|
|
assert probe.allowed and probe.is_probe and probe.probe_owner == "w1"
|
|
second = await gate.try_enter("s1", "w2")
|
|
assert not second.allowed and second.state is GateState.HALF_OPEN
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_probe_success_closes(redis_client):
|
|
gate = _gate(redis_client)
|
|
await _open_gate(gate)
|
|
await asyncio.sleep(_CFG.cooldown_s + 1)
|
|
probe = await gate.try_enter("s1", "w1")
|
|
update = await gate.record_success(probe)
|
|
assert update.applied and update.state is GateState.CLOSED
|
|
assert (await gate.try_enter("s1", "w2")).allowed
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_probe_failure_reopens(redis_client):
|
|
gate = _gate(redis_client)
|
|
await _open_gate(gate)
|
|
await asyncio.sleep(_CFG.cooldown_s + 1)
|
|
probe = await gate.try_enter("s1", "w1")
|
|
update = await gate.record_failure(probe, "network_error", False)
|
|
assert update.state is GateState.OPEN
|
|
assert not (await gate.try_enter("s1", "w2")).allowed
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_probe_lease_expiry_allows_takeover(redis_client):
|
|
"""探针持有者死亡 → 120s 租约过期后新 caller 接管,防死锁。"""
|
|
gate = _gate(redis_client)
|
|
await _open_gate(gate)
|
|
await asyncio.sleep(_CFG.cooldown_s + 1)
|
|
stale = await gate.try_enter("s1", "dead-worker")
|
|
assert stale.is_probe
|
|
await asyncio.sleep(_CFG.probe_ttl_s + 1)
|
|
takeover = await gate.try_enter("s1", "w2")
|
|
assert takeover.allowed and takeover.is_probe and takeover.probe_owner == "w2"
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_release_probe_hands_back_and_idempotent(redis_client):
|
|
gate = _gate(redis_client)
|
|
await _open_gate(gate)
|
|
await asyncio.sleep(_CFG.cooldown_s + 1)
|
|
probe = await gate.try_enter("s1", "w1")
|
|
update = await gate.release_probe(probe)
|
|
assert update.applied
|
|
assert not (await gate.release_probe(probe)).applied # 幂等
|
|
nxt = await gate.try_enter("s1", "w2")
|
|
assert nxt.allowed and nxt.is_probe
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_stale_probe_owner_rejected(redis_client):
|
|
gate = _gate(redis_client)
|
|
await _open_gate(gate)
|
|
await asyncio.sleep(_CFG.cooldown_s + 1)
|
|
stale_probe = await gate.try_enter("s1", "dead-worker")
|
|
await asyncio.sleep(_CFG.probe_ttl_s + 1)
|
|
takeover = await gate.try_enter("s1", "w2")
|
|
assert takeover.is_probe
|
|
assert not (await gate.record_success(stale_probe)).applied # owner 已易主被 fence
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_variant_retry_after_semantics(redis_client):
|
|
gate = _gate(redis_client)
|
|
assert await gate.retry_after_s(("s1",)) == 0.0
|
|
await _open_gate(gate)
|
|
wait = await gate.retry_after_s(("s1",))
|
|
assert 0 < wait <= _CFG.cooldown_s
|
|
await asyncio.sleep(_CFG.cooldown_s + 1)
|
|
assert await gate.retry_after_s(("s1",)) == 0.0
|
|
with pytest.raises(ValueError):
|
|
await gate.retry_after_s(())
|
|
|
|
|
|
# —— 窗口翻滚(契约外补充,CHS test_redis_limiter.py 集成防抖同款)——
|
|
|
|
|
|
@pytestmark_slow
|
|
async def test_rpm_window_rollover_resets_quota(redis_client):
|
|
"""RPM 固定分钟窗口: 打满被拒 → 服务器时钟翻分钟后配额刷新。"""
|
|
await await_window_headroom(redis_client, min_headroom_s=15)
|
|
limiter = _limiter(redis_client, [make_source(rpm=2)])
|
|
for _ in range(2):
|
|
permit = await limiter.try_acquire("s1", 0)
|
|
assert permit is not None
|
|
await permit.release()
|
|
assert await limiter.try_acquire("s1", 0) is None
|
|
sec, _ = await redis_client.time()
|
|
await asyncio.sleep(60 - int(sec) % 60 + 1) # 睡到下一分钟边界
|
|
fresh = await limiter.try_acquire("s1", 0)
|
|
assert fresh is not None
|
|
await fresh.release()
|