fix: telemetry single persistent connection + lock (kill concurrent write lock)

This commit is contained in:
2026-07-16 08:47:23 -04:00
parent 766592d855
commit 065c8ae1b9
3 changed files with 85 additions and 27 deletions
+34 -25
View File
@@ -8,13 +8,11 @@ from __future__ import annotations
import asyncio import asyncio
import sqlite3 import sqlite3
from typing import TYPE_CHECKING import threading
from pathlib import Path
from loguru import logger from loguru import logger
if TYPE_CHECKING:
from pathlib import Path
class SQLiteTelemetryRecorder: class SQLiteTelemetryRecorder:
"""基于 SQLite 的 LLM 调用遥测记录器。 """基于 SQLite 的 LLM 调用遥测记录器。
@@ -57,16 +55,30 @@ class SQLiteTelemetryRecorder:
""" """
def __init__(self, db_path: Path) -> None: def __init__(self, db_path: Path) -> None:
self._db_path = db_path """建单持久连接 + 进程内 Lock(对齐 app/harness/log.py:HarnessLog 并发写模式)。
self._table_ready = False
def _ensure_table(self, conn: sqlite3.Connection) -> None: 把并发控制拉到进程内(threading.Lock 串行化写),消除"每次新连接并发写同一
"""懒初始化:首次写入时创建 llm_calls 表。""" db、靠 SQLite busy_timeout 跨连接协调"在高频下撑爆 timeout → database is locked
if self._table_ready: 的根因。check_same_thread=Falserecord_llm_call 经 asyncio.to_thread 在线程池
return 不同线程调用,共享连接跨线程访问需此 flag,串行性由 self._lock 保证。
遥测哲学(P5):连接初始化失败降级不冒泡(self._conn=None,写入直接丢弃 warning),
绝不因遥测故障拖垮 LLM 调用 / 训练。
"""
self._db_path = db_path
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
try:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(self._CREATE_TABLE_SQL) conn.execute(self._CREATE_TABLE_SQL)
conn.commit() conn.commit()
self._table_ready = True self._conn = conn
except sqlite3.Error as exc:
logger.warning("遥测连接初始化失败(已降级,后续写入丢弃): {}", exc)
self._conn = None
def _write( def _write(
self, self,
@@ -87,20 +99,19 @@ class SQLiteTelemetryRecorder:
cache_hit: bool, cache_hit: bool,
error: str | None, error: str | None,
) -> None: ) -> None:
"""同步写入一条 LLM 调用记录到 SQLite """同步写入一条 LLM 调用记录(单持久连接 + Lock 串行化,对齐 HarnessLog
三层防御加固 三层防御:
1. INSERT OR IGNORE — 主键冲突静默忽略 1. INSERT OR IGNORE — call_id 主键冲突静默忽略(幂等)
2. WAL + busy_timeout — 并发写锁容忍 2. 进程内 threading.Lock 串行化写 — 消除并发锁竞争(非依赖 SQLite busy_timeout
3. try/except sqlite3.Error — DB 错误不冒泡调用 3. try/except sqlite3.Error — DB 错误降级不冒泡,遥测失败绝不拖垮 LLM 调用
""" """
if self._conn is None:
logger.warning("遥测连接不可用(已降级),丢弃 call_id={}", call_id)
return
try: try:
conn = sqlite3.connect(str(self._db_path), timeout=10.0) with self._lock:
try: self._conn.execute(
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
self._ensure_table(conn)
conn.execute(
self._INSERT_SQL, self._INSERT_SQL,
( (
call_id, call_id,
@@ -120,9 +131,7 @@ class SQLiteTelemetryRecorder:
error, error,
), ),
) )
conn.commit() self._conn.commit()
finally:
conn.close()
except sqlite3.Error as exc: except sqlite3.Error as exc:
logger.warning("遥测写入失败(已降级),call_id={}: {}", call_id, exc) logger.warning("遥测写入失败(已降级),call_id={}: {}", call_id, exc)
+2
View File
@@ -69,6 +69,8 @@ class HarnessLog:
self._run_id = run_id self._run_id = run_id
self._register_run = register_run self._register_run = register_run
Path(db_path).parent.mkdir(parents=True, exist_ok=True) Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# 单持久连接 + 进程内 Lock 串行化写:把并发控制拉到进程内,消除多连接争
# SQLite 写锁。同款模式复用于 adapters/telemetry.py:SQLiteTelemetryRecorder。
self._conn = sqlite3.connect(db_path, check_same_thread=False) self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._lock = threading.Lock() self._lock = threading.Lock()
self._conn.row_factory = sqlite3.Row self._conn.row_factory = sqlite3.Row
+47
View File
@@ -146,3 +146,50 @@ async def test_concurrent_writes_no_lock_error(recorder, db_path):
count = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM llm_calls").fetchone()[0]
conn.close() conn.close()
assert count == 16 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}
real_connect = sqlite3.connect
def _counting_connect(*args, **kwargs):
connect_calls["n"] += 1
return real_connect(*args, **kwargs)
monkeypatch.setattr(sqlite3, "connect", _counting_connect)
recorder = SQLiteTelemetryRecorder(db_path=db_path)
after_init = connect_calls["n"]
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} 个连接(应复用单持久连接,"
"每次新连接是并发锁竞争根源)"
)