206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
"""CacheMW 与缓存 key 公式测试(ARCH §7.5: 防毒化 key、命中重建、静默降级)。"""
|
|
|
|
import dataclasses
|
|
import hashlib
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from polygateway.backends.memory.cache import InMemoryCache
|
|
from polygateway.errors import ResultInvalidError, TransientError
|
|
from polygateway.middleware.cache import CacheMW, build_cache_key, digest_messages
|
|
from polygateway.types import ChatRequest, LLMResponse
|
|
|
|
_MSGS = [{"role": "user", "content": "hi"}]
|
|
|
|
|
|
def _resp(content="cached", **overrides):
|
|
base = dict(
|
|
content=content, thinking="", model="m", provider="p",
|
|
prompt_tokens=1, completion_tokens=2, latency_ms=30,
|
|
ttft_ms=5.0, max_inter_token_ms=2.0, cache_hit=False, call_id="orig",
|
|
source_name="s1", usage_source="measured",
|
|
)
|
|
base.update(overrides)
|
|
return LLMResponse(**base)
|
|
|
|
|
|
class TestKeyFormula:
|
|
def test_same_request_same_key(self):
|
|
k1 = build_cache_key("m", _MSGS, "proj", None)
|
|
k2 = build_cache_key("m", _MSGS, "proj", None)
|
|
assert k1 == k2 and k1.startswith("pgw:cache:")
|
|
|
|
@pytest.mark.parametrize(
|
|
("a", "b"),
|
|
[
|
|
(("m1", _MSGS, "proj", None), ("m2", _MSGS, "proj", None)),
|
|
(("m", _MSGS, "proj", None), ("m", _MSGS, "tenant2", None)),
|
|
(("m", _MSGS, "proj", None), ("m", _MSGS, "proj", "epoch2")),
|
|
(("m", _MSGS, "proj", "s1"), ("m", _MSGS, "proj", "s2")),
|
|
(("m", _MSGS, "proj", None), ("m", [{"role": "user", "content": "yo"}], "proj", None)),
|
|
],
|
|
)
|
|
def test_any_dimension_change_changes_key(self, a, b):
|
|
assert build_cache_key(*a) != build_cache_key(*b)
|
|
|
|
def test_multimodal_part_digested_not_inlined(self):
|
|
big_b64 = "data:image/png;base64," + "A" * 1_000_000
|
|
messages = [{"role": "user", "content": [
|
|
{"type": "image_url", "image_url": {"url": big_b64}},
|
|
{"type": "text", "text": "describe"},
|
|
]}]
|
|
digested = digest_messages(messages)
|
|
payload = json.dumps(digested, ensure_ascii=False)
|
|
assert len(payload) < 500 # 大图不进 canonical_json
|
|
expected = hashlib.sha256(big_b64.encode()).hexdigest()
|
|
assert expected in payload # 但字节变化仍改变 key
|
|
# 图像字节变化 → key 变
|
|
messages2 = [{"role": "user", "content": [
|
|
{"type": "image_url", "image_url": {"url": big_b64[:-1] + "B"}},
|
|
{"type": "text", "text": "describe"},
|
|
]}]
|
|
assert build_cache_key("m", messages, "p", None) != build_cache_key("m", messages2, "p", None)
|
|
|
|
|
|
class _Terminal:
|
|
def __init__(self, response):
|
|
self.response = response
|
|
self.calls = 0
|
|
|
|
async def __call__(self, request):
|
|
self.calls += 1
|
|
if isinstance(self.response, Exception):
|
|
raise self.response
|
|
return self.response
|
|
|
|
|
|
def _mw(backend, **kwargs):
|
|
defaults = dict(backend=backend, model_fingerprint="m", default_namespace="proj", ttl_s=3600)
|
|
defaults.update(kwargs)
|
|
return CacheMW(**defaults)
|
|
|
|
|
|
class TestCacheFlow:
|
|
async def test_miss_then_hit_with_fresh_call_id(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(_resp())
|
|
first = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert first.cache_hit is False and terminal.calls == 1
|
|
second = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert second.cache_hit is True and second.latency_ms == 0
|
|
assert second.content == "cached"
|
|
assert second.call_id != first.call_id # 命中生成独立 cache_call_id
|
|
assert terminal.calls == 1 # 未再触达内层
|
|
|
|
async def test_per_call_namespace_overrides_default(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
terminal = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS, cache_namespace="tenant-a"), terminal)
|
|
# 另一租户不得命中
|
|
await mw(ChatRequest(messages=_MSGS, cache_namespace="tenant-b"), terminal)
|
|
assert terminal.calls == 2
|
|
|
|
async def test_failure_not_cached(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
failing = _Terminal(TransientError("boom"))
|
|
with pytest.raises(TransientError):
|
|
await mw(ChatRequest(messages=_MSGS), failing)
|
|
ok = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS), ok)
|
|
assert ok.calls == 1 # 失败未被固化,正常回源
|
|
|
|
async def test_ttl_expiry(self):
|
|
t = {"now": 0.0}
|
|
backend = InMemoryCache(now=lambda: t["now"])
|
|
mw = _mw(backend, ttl_s=100)
|
|
terminal = _Terminal(_resp())
|
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
|
t["now"] = 101.0
|
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert terminal.calls == 2
|
|
|
|
|
|
class _BrokenBackend:
|
|
async def get(self, key):
|
|
raise ConnectionError("redis down")
|
|
|
|
async def set(self, key, value, ttl_s):
|
|
raise ConnectionError("redis down")
|
|
|
|
|
|
class TestDegradation:
|
|
async def test_backend_failure_degrades_silently(self):
|
|
mw = _mw(_BrokenBackend())
|
|
terminal = _Terminal(_resp())
|
|
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert resp.content == "cached" and terminal.calls == 1 # 读写全降级,调用照常
|
|
|
|
async def test_corrupt_cache_value_treated_as_miss(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend)
|
|
key = build_cache_key("m", _MSGS, "proj", None)
|
|
await backend.set(key, "{not json", 3600)
|
|
terminal = _Terminal(_resp())
|
|
resp = await mw(ChatRequest(messages=_MSGS), terminal)
|
|
assert terminal.calls == 1 and resp.cache_hit is False
|
|
|
|
|
|
class _FakeStrategy:
|
|
"""fake StructuredOutputStrategy(T2 冻结的 Protocol,不依赖 T11)。"""
|
|
|
|
def request_overlay(self, schema):
|
|
return {}
|
|
|
|
def parse(self, text):
|
|
data = json.loads(text) # 简化: 直接 json.loads
|
|
if not isinstance(data, dict):
|
|
raise ResultInvalidError("非对象", raw_text=text)
|
|
return data
|
|
|
|
|
|
class _StrictModel:
|
|
"""鸭子型 pydantic 模型: model_validate 要求含 answer 键。"""
|
|
|
|
@classmethod
|
|
def model_validate(cls, data):
|
|
if "answer" not in data:
|
|
raise ValueError("missing answer")
|
|
return {"validated": data["answer"]}
|
|
|
|
|
|
class TestStructuredRehydration:
|
|
async def test_hit_rebuilds_structured_data(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend, strategy=_FakeStrategy())
|
|
terminal = _Terminal(_resp(content='{"answer": 42}'))
|
|
req = ChatRequest(messages=_MSGS, structured=_StrictModel)
|
|
await mw(req, terminal)
|
|
hit = await mw(req, terminal)
|
|
assert hit.cache_hit is True
|
|
assert hit.structured_data == {"validated": 42}
|
|
assert terminal.calls == 1
|
|
|
|
async def test_schema_change_revalidation_failure_falls_back_to_source(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend, strategy=_FakeStrategy())
|
|
terminal = _Terminal(_resp(content='{"other": 1}')) # 缓存内容不含 answer
|
|
await mw(ChatRequest(messages=_MSGS), terminal) # 无 structured 写入
|
|
# 换 schema 读: 重校验失败 → 按未命中回源
|
|
again = await mw(ChatRequest(messages=_MSGS, structured=_StrictModel), terminal)
|
|
assert terminal.calls == 2 and again.cache_hit is False
|
|
|
|
async def test_structured_data_not_serialized_into_cache(self):
|
|
backend = InMemoryCache()
|
|
mw = _mw(backend, strategy=_FakeStrategy())
|
|
terminal = _Terminal(
|
|
dataclasses.replace(_resp(content='{"answer": 1}'), structured_data={"x": object()})
|
|
)
|
|
await mw(ChatRequest(messages=_MSGS), terminal) # 不可 JSON 的 structured_data 不阻塞写缓存
|
|
key = build_cache_key("m", _MSGS, "proj", None)
|
|
raw = await backend.get(key)
|
|
assert raw is not None and "structured_data" not in json.loads(raw)
|