Files
Video-Tree-TRM5/tests/unit/test_telemetry.py
T

231 lines
7.9 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.
"""adapters/telemetry.py 单元测试 — SQLiteTelemetryRecorder。"""
from __future__ import annotations
import sqlite3
import uuid
import pytest
from adapters.telemetry import SQLiteTelemetryRecorder
from core.protocols import TelemetryRecorder
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_path(tmp_path):
"""返回临时数据库路径。"""
return tmp_path / "telemetry_test.db"
@pytest.fixture()
def recorder(db_path):
"""构造 SQLiteTelemetryRecorder 实例。"""
return SQLiteTelemetryRecorder(db_path=db_path)
def _make_call_kwargs(*, cache_hit: bool = False, error: str | None = None):
"""构造 record_llm_call 的标准参数字典。"""
return dict(
call_id=str(uuid.uuid4()),
parent_call_id=None,
session_id="sess-001",
model_name="gpt-4o",
provider="openai",
messages='[{"role":"user","content":"hi"}]',
response="hello",
thinking="",
prompt_tokens=10,
completion_tokens=5,
latency_ms=120,
ttft_ms=45.2,
max_inter_token_ms=12.3,
cache_hit=cache_hit,
error=error,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_satisfies_protocol(recorder):
"""SQLiteTelemetryRecorder 满足 TelemetryRecorder Protocol。"""
assert isinstance(recorder, TelemetryRecorder)
@pytest.mark.asyncio
async def test_record_creates_table_and_inserts(recorder, db_path):
"""首次写入应懒创建表并成功插入一条记录。"""
kwargs = _make_call_kwargs()
await recorder.record_llm_call(**kwargs)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
rows = conn.execute("SELECT * FROM llm_calls").fetchall()
conn.close()
assert len(rows) == 1
row = rows[0]
assert row["call_id"] == kwargs["call_id"]
assert row["model_name"] == "gpt-4o"
assert row["prompt_tokens"] == 10
assert row["completion_tokens"] == 5
assert row["cache_hit"] == 0 # False → INTEGER 0
assert row["error"] is None
assert row["created_at"] is not None
@pytest.mark.asyncio
async def test_record_with_error(recorder, db_path):
"""error 字段非 None 时应正确存储。"""
kwargs = _make_call_kwargs(error="RateLimitError: 429")
await recorder.record_llm_call(**kwargs)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT error FROM llm_calls WHERE call_id = ?", (kwargs["call_id"],)
).fetchone()
conn.close()
assert row["error"] == "RateLimitError: 429"
@pytest.mark.asyncio
async def test_record_cache_hit(recorder, db_path):
"""cache_hit=True 时应存储为 INTEGER 1。"""
kwargs = _make_call_kwargs(cache_hit=True)
await recorder.record_llm_call(**kwargs)
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT cache_hit FROM llm_calls WHERE call_id = ?", (kwargs["call_id"],)
).fetchone()
conn.close()
assert row["cache_hit"] == 1
@pytest.mark.asyncio
async def test_duplicate_call_id_does_not_raise(recorder, db_path):
"""重复 call_id 写入应静默忽略(INSERT OR IGNORE),不抛异常。"""
kwargs = _make_call_kwargs()
await recorder.record_llm_call(**kwargs)
await recorder.record_llm_call(**kwargs)
conn = sqlite3.connect(str(db_path))
rows = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()
conn.close()
assert rows[0] == 1
@pytest.mark.asyncio
async def test_init_db_error_does_not_propagate(db_path, monkeypatch):
"""构造期 connect 失败应降级(self._conn=None),不冒泡拖垮初始化。"""
def _boom_connect(*args, **kwargs):
raise sqlite3.OperationalError("unable to open database file")
monkeypatch.setattr(sqlite3, "connect", _boom_connect)
recorder = SQLiteTelemetryRecorder(db_path=db_path) # 不抛
assert recorder._conn is None
await recorder.record_llm_call(**_make_call_kwargs()) # 写也降级不抛
@pytest.mark.asyncio
async def test_init_mkdir_error_does_not_propagate(db_path, monkeypatch):
"""构造期 mkdir 失败(PermissionError/OSError)也应降级,不冒泡。"""
def _boom_mkdir(*args, **kwargs):
raise PermissionError("cannot create dir")
monkeypatch.setattr("adapters.telemetry.Path.mkdir", _boom_mkdir)
recorder = SQLiteTelemetryRecorder(db_path=db_path) # 不抛
assert recorder._conn is None
@pytest.mark.asyncio
async def test_write_db_error_does_not_propagate(recorder):
"""写入期 execute 失败应静默降级,不抛异常(遥测失败绝不拖垮 LLM 调用)。"""
class _BoomConn:
def execute(self, *args, **kwargs):
raise sqlite3.OperationalError("disk I/O error")
def commit(self):
pass
recorder._conn = _BoomConn() # 连接存在但写抛错,验证 _write 的 except 降级
await recorder.record_llm_call(**_make_call_kwargs()) # 降级不抛
@pytest.mark.asyncio
async def test_concurrent_writes_no_lock_error(recorder, db_path):
"""16 路并发 record_llm_call 应全部成功,无 database is locked 错误。"""
import asyncio
tasks = []
for _ in range(16):
kwargs = _make_call_kwargs()
tasks.append(recorder.record_llm_call(**kwargs))
await asyncio.gather(*tasks)
conn = sqlite3.connect(str(db_path))
count = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()[0]
conn.close()
assert count == 16
def test_high_concurrency_writes_zero_loss(recorder, db_path):
"""64 路线程并发直压 _write 应零丢失——复现生产 concurrency 下 database is locked 丢失。
直接压同步 _write(不经 to_thread 排队),最大化并发连接数以逼出锁竞争;
单连接 + threading.Lock 串行化模式下应全部落库、零丢失(对齐 HarnessLog)。
"""
import concurrent.futures
n = 64
kwargs_list = [_make_call_kwargs() for _ in range(n)]
with concurrent.futures.ThreadPoolExecutor(max_workers=n) as executor:
list(executor.map(lambda kw: recorder._write(**kw), kwargs_list))
conn = sqlite3.connect(str(db_path))
count = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()[0]
conn.close()
assert count == n, f"并发写丢失 {n - count} 条(database is locked 降级丢弃): 落库 {count}/{n}"
def test_uses_single_persistent_connection(db_path, monkeypatch):
"""对齐 HarnessLog:单持久连接(构造时建一次),写入复用而非每次新建。
每次写新建连接是并发锁竞争根源(多连接争 SQLite 写锁,撑爆 busy_timeout);
单连接 + 进程内 Lock 串行化把并发控制拉到进程内,消除 SQLite 层锁竞争。
"""
connect_calls = {"n": 0, "kwargs": None}
real_connect = sqlite3.connect
def _counting_connect(*args, **kwargs):
connect_calls["n"] += 1
connect_calls["kwargs"] = kwargs
return real_connect(*args, **kwargs)
monkeypatch.setattr(sqlite3, "connect", _counting_connect)
recorder = SQLiteTelemetryRecorder(db_path=db_path)
after_init = connect_calls["n"]
# 跨线程共享连接必须 check_same_thread=False(串行性由 self._lock 保证)
assert connect_calls["kwargs"].get("check_same_thread") is False
for _ in range(10):
recorder._write(**_make_call_kwargs())
after_writes = connect_calls["n"]
assert after_init >= 1, "构造时应建立持久连接(对齐 HarnessLog"
assert after_writes == after_init, (
f"写入期间新建了 {after_writes - after_init} 个连接(应复用单持久连接,"
"每次新连接是并发锁竞争根源)"
)