95 lines
2.9 KiB
Python
95 lines
2.9 KiB
Python
"""cache.py 本地层(不碰 Redis):TTL 过期、容量淘汰、熔断冷却恢复。"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
import app.cache as cache_mod
|
||
from app import cache
|
||
|
||
|
||
class _FakeClock:
|
||
"""替换 cache 命名空间里的 time(不影响全局 time 模块)。"""
|
||
|
||
def __init__(self):
|
||
self.now = 1000.0
|
||
|
||
def monotonic(self) -> float:
|
||
return self.now
|
||
|
||
def time(self) -> float:
|
||
return self.now
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_state():
|
||
"""每个用例干净的本地缓存状态。"""
|
||
cache._local_store.clear()
|
||
cache._local_bytes = 0
|
||
cache._disabled_until = 0.0
|
||
yield
|
||
cache._local_store.clear()
|
||
cache._local_bytes = 0
|
||
cache._disabled_until = 0.0
|
||
|
||
|
||
def test_local_set_get_roundtrip():
|
||
cache.local_set("k", '{"a":1}', ttl=60)
|
||
assert cache.local_get("k") == '{"a":1}'
|
||
|
||
|
||
def test_local_get_miss():
|
||
assert cache.local_get("nope") is None
|
||
|
||
|
||
def test_local_expiry(monkeypatch):
|
||
clock = _FakeClock()
|
||
monkeypatch.setattr(cache_mod, "time", clock)
|
||
cache.local_set("k", "v", ttl=10)
|
||
clock.now = 1005.0
|
||
assert cache.local_get("k") == "v"
|
||
clock.now = 1101.0 # 过期
|
||
assert cache.local_get("k") is None
|
||
assert "k" not in cache._local_store # 过期读取顺手清理
|
||
|
||
|
||
def test_local_ttl_capped_at_120s():
|
||
"""本地层恒 ≤120s(多进程部署时最多比 Redis 多陈旧 120s 的约定)。"""
|
||
base = cache.time.monotonic()
|
||
cache.local_set("k", "v", ttl=99999)
|
||
ent = cache._local_store["k"]
|
||
assert ent[0] - base <= 120.0 + 5 # 相对当前 monotonic 的上限(留误差余量)
|
||
|
||
|
||
def test_local_entries_cap_evicts_oldest():
|
||
for i in range(cache._LOCAL_MAX_ENTRIES + 5):
|
||
cache.local_set(f"k{i}", "v", ttl=60)
|
||
assert len(cache._local_store) <= cache._LOCAL_MAX_ENTRIES
|
||
# 先插入的(最旧)被近似 LRU 淘汰
|
||
assert cache.local_get("k0") is None
|
||
assert cache.local_get(f"k{cache._LOCAL_MAX_ENTRIES + 4}") == "v"
|
||
|
||
|
||
def test_local_overwrite_releases_bytes():
|
||
cache.local_set("k", "x" * 1000, ttl=60)
|
||
before = cache._local_bytes
|
||
cache.local_set("k", "y", ttl=60)
|
||
assert cache._local_bytes < before
|
||
assert cache.local_get("k") == "y"
|
||
|
||
|
||
def test_bail_cooldown_recovers(monkeypatch):
|
||
"""熔断 60s:期间 _client 为 None,到期自动放行(Redis 未配置时也返回 None,但不熔断)。"""
|
||
clock = _FakeClock()
|
||
monkeypatch.setattr(cache_mod, "time", clock)
|
||
cache._bail()
|
||
assert cache._disabled_until > clock.now
|
||
clock.now += 59.0
|
||
assert clock.monotonic() < cache._disabled_until # 仍在熔断期
|
||
clock.now += 2.0 # 越过 60s 冷却
|
||
assert clock.monotonic() >= cache._disabled_until # 恢复探测资格
|
||
|
||
|
||
def test_digest_stable_and_distinct():
|
||
assert cache.digest("a", 1, None) == cache.digest("a", 1, None)
|
||
assert cache.digest("a", 1) != cache.digest("a", 2)
|