fix: telemetry single persistent connection + lock (kill concurrent write lock)
This commit is contained in:
+36
-27
@@ -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=False:record_llm_call 经 asyncio.to_thread 在线程池
|
||||||
return
|
不同线程调用,共享连接跨线程访问需此 flag,串行性由 self._lock 保证。
|
||||||
conn.execute(self._CREATE_TABLE_SQL)
|
|
||||||
conn.commit()
|
遥测哲学(P5):连接初始化失败降级不冒泡(self._conn=None,写入直接丢弃 warning),
|
||||||
self._table_ready = True
|
绝不因遥测故障拖垮 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.commit()
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} 个连接(应复用单持久连接,"
|
||||||
|
"每次新连接是并发锁竞争根源)"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user