Files
meida_backend/tests/integration/test_retrieval_cache.py
T
xsl a044ea2e47 feat(retrieval): TTL+LRU result cache with generation-based invalidation
TtlCache(全输入进键/惰性过期/容量逐出/命中零 DB)+ search 接线
(空结果不缓存,保住 gap 埋点)+ invalidate_cache 真实现(T-P2-10)。

实现:
- packages/retrieval/cache.py(新建):CacheKey 全输入进键(query/body_shape/
  occasion/entity_types 排序归一/rule_boost 排序序列化哈希/top_k/失效代数);
  TtlCache 惰性过期 + OrderedDict LRU(超限 popitem 最旧)+ hits/misses/
  evictions 计数与 stats();clock 可注入(单测免 sleep,默认 monotonic);
  asyncio 单循环不加锁的前提写在模块 docstring;正确性红线(只缓存不可变
  值、命中零 DB)同处。纯逻辑,不 import 任何项目内模块(值类型由 service
  以 PEP 695 泛型注入,避免循环依赖)。
- packages/retrieval/service.py:__init__ 加 keyword-only cache=None(默认
  禁用,行为与现状一致,既有调用点不受影响);from_settings 按 Settings
  装配进程级共享缓存(retrieval_cache_enabled=False → None;TTL/容量变化
  时重建);search() 在 embed 之前查缓存——命中直接返回(零 DB、零 embed),
  写入与命中都做防御性拷贝(results 是 list,调用方拿不到缓存内引用),
  仅有结果时写缓存(空结果不缓存,record_gap 每次都跑);invalidate_cache()
  签名不变:自增 repo.INVALIDATION_VERSION(代数失效,旧代键永不命中,
  与 T12 的契约保持)+ 清空共享实例(仅为立即回收内存,失效正确性只
  依赖代数)。repo.py 未做任何改动。

测试:
- 单测 tests/unit/test_retrieval_cache.py(21 例,fake repo/embedding +
  注入时钟,不连库):同参二连查零 DB 且结果相等、键分量矩阵(query/
  occasion/body_shape/entity_types/rule_boost/top_k 任一变化 → miss)、
  TTL 过期边界(60.0 过期/59.9 命中)、LRU 逐出与命中率/计数、空结果
  不缓存且 gap 埋点每次触发、invalidate_cache() 代数失效(含自增语义
  保留断言)、cache=None 与现状一致、from_settings 装配开关与共享实例、
  rule_boost 哈希确定性。全量单测 494 passed(基线 473 + 新增 21)。
- 集成 tests/integration/test_retrieval_cache.py(2 例,真实 PG + pgvector):
  发布新实体 + invalidate_cache() 后同参检索立即可见(失效前缓存仍只吐
  旧实体,证明缓存在工作——T-P2-10 验收语义);不失效、TTL=1s 到期
  (asyncio.sleep 1.1s)后新实体自然可见(双路径验收)。集成全量 40 passed。

偏差说明:无(invalidate_cache() 在代数失效之外顺带清空共享缓存实例,
失效正确性只依赖代数,清空仅为立即释放内存)。
2026-08-14 21:16:51 +08:00

168 lines
6.5 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.
"""检索缓存集成(T-P2-10):真实 PostgreSQL + pgvector,DeterministicEmbedder 不联网.
证明单测证明不了的两条失效路径(任务文档 §4,双路径验收):
1. 发布新实体 + ``invalidate_cache()`` → 同参数检索立即可见新实体;
2. 不失效、等 TTL 自然到期(TTL=1s,sleep 1.1s)→ 新实体同样可见。
Run: MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m integration -q --no-cov
"""
from __future__ import annotations
import asyncio
import os
import uuid
from collections.abc import AsyncIterator
from typing import Any
import pytest
from sqlalchemy import delete
from packages.domain.ids import next_id
from packages.retrieval.cache import TtlCache
from packages.retrieval.embedding import DeterministicEmbedder
from packages.retrieval.repo import ChunkInput, upsert_chunks
from packages.retrieval.service import RetrievalService, invalidate_cache
from packages.shared.db import dispose_engine, session_scope
from packages.shared.models import KbAsset, KbChunk, KbEntity, KbGap
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
def _unique(name: str) -> str:
return f"it-t14-{name}-{uuid.uuid4().hex[:10]}"
@pytest.fixture(autouse=True)
async def _hermetic_engine() -> AsyncIterator[None]:
"""每个用例前后重建全局 engine:asyncpg 连接绑定创建它的事件循环,
pytest-asyncio 给每个用例一个新 loop,复用池里旧连接会报
"attached to a different loop"。"""
await dispose_engine()
yield
await dispose_engine()
@pytest.fixture
async def clean_kb() -> AsyncIterator[dict[str, list[Any]]]:
"""登记本用例造的脏数据并在结束时清掉:集成库长活,不能留假实体."""
registry: dict[str, list[Any]] = {"entities": [], "gaps": []}
yield registry
async with session_scope() as session:
if registry["entities"]:
ids = registry["entities"]
await session.execute(delete(KbChunk).where(KbChunk.entity_id.in_(ids)))
await session.execute(delete(KbAsset).where(KbAsset.entity_id.in_(ids)))
await session.execute(delete(KbEntity).where(KbEntity.entity_id.in_(ids)))
for query_text in registry["gaps"]:
await session.execute(delete(KbGap).where(KbGap.query_text == query_text))
async def _publish_entity_with_chunk(
registry: dict[str, list[Any]], embedder: DeterministicEmbedder, *, chunk_text: str
) -> int:
"""直接以 published 落库(任务文档允许「SQL 置 published」).
用例聚焦缓存失效语义,不走发布状态机;chunk 与查询同文保证向量路
距离 0,必然被召回。
"""
entity_id = next_id()
registry["entities"].append(entity_id)
async with session_scope() as session:
session.add(
KbEntity(
entity_id=entity_id,
entity_type="item",
name=_unique("item"),
attrs={},
status="published",
)
)
# sessionmaker 是 autoflush=False:不显式 flush,挂起的实体 INSERT
# 不会先于 kb_chunk 下发,FK 会拦下 chunk。
await session.flush()
await upsert_chunks(session, entity_id, [ChunkInput(text=chunk_text)], embedder)
return entity_id
def _seen_entities(outcome: Any) -> set[int]:
return {r.entity_id for r in outcome.results}
# --- 1. 发布 + invalidate_cache() → 新实体立即可见(T-P2-10 验收语义)---------
async def test_publish_then_invalidate_makes_new_entity_visible(
clean_kb: dict[str, list[Any]],
) -> None:
embedder = DeterministicEmbedder(dimensions=1024)
query = _unique("invalidate-query")
# 防御性登记:若回归导致空结果被记进 kb_gap,用例结束也能清干净。
clean_kb["gaps"].append(query)
entity_a = await _publish_entity_with_chunk(clean_kb, embedder, chunk_text=query)
cache: TtlCache[Any] = TtlCache(ttl_seconds=60.0, max_entries=64)
async with session_scope() as session:
svc = RetrievalService(session, embedding=embedder, cache=cache)
first = await svc.search(query)
second = await svc.search(query)
assert first.degraded is False and second.degraded is False
assert _seen_entities(first) == {entity_a}
assert second == first
assert cache.stats()["hits"] == 1 # 第二次命中缓存:零 DB、零 embed
entity_b = await _publish_entity_with_chunk(clean_kb, embedder, chunk_text=query)
# 未失效前缓存照旧只吐 A —— 证明缓存真的在工作(不是每次都查库)
async with session_scope() as session:
svc = RetrievalService(session, embedding=embedder, cache=cache)
stale = await svc.search(query)
assert _seen_entities(stale) == {entity_a}
invalidate_cache() # 发布编排(admin/T12)调用的钩子
async with session_scope() as session:
svc = RetrievalService(session, embedding=embedder, cache=cache)
fresh = await svc.search(query)
assert _seen_entities(fresh) == {entity_a, entity_b} # 新实体立即可见
# --- 2. 不失效,等 TTL 到期 → 新实体自然可见(双保险)-------------------------
async def test_ttl_expiry_makes_new_entity_visible_without_invalidation(
clean_kb: dict[str, list[Any]],
) -> None:
embedder = DeterministicEmbedder(dimensions=1024)
query = _unique("ttl-query")
clean_kb["gaps"].append(query)
entity_a = await _publish_entity_with_chunk(clean_kb, embedder, chunk_text=query)
cache: TtlCache[Any] = TtlCache(ttl_seconds=1.0, max_entries=64)
async with session_scope() as session:
svc = RetrievalService(session, embedding=embedder, cache=cache)
first = await svc.search(query)
await svc.search(query)
assert _seen_entities(first) == {entity_a}
assert cache.stats()["hits"] == 1 # TTL 内命中缓存
entity_b = await _publish_entity_with_chunk(clean_kb, embedder, chunk_text=query)
# 不调用 invalidate_cache():只等 TTL 自然到期(1s + 余量 0.1s)。
# asyncio.sleep 与 TtlCache 的默认时钟同为单调时钟,无校时跳变风险。
await asyncio.sleep(1.1)
async with session_scope() as session:
svc = RetrievalService(session, embedding=embedder, cache=cache)
fresh = await svc.search(query)
assert _seen_entities(fresh) == {entity_a, entity_b} # 到期后重新检索