Files
Video-Tree-TRM5/research-wiki/designs/2026-07-16-telemetry-concurrency-fix-design.md
T

6.3 KiB
Raw Blame History

遥测 SQLite 高并发写锁修复设计

2026-07-16。修复 SQLiteTelemetryRecorder 在 concurrency≥12 时 database is locked、遥测大量丢失的问题。诉求:训练(concurrency 24)时遥测零丢失——保住"详细 agent 日志用于回溯分析"这一核心诉求。

1. 根因(对照 HarnessLog 暴露)

同样高并发写 SQLiteapp/harness/log.py:HarnessLog 从不锁死,adapters/telemetry.py:SQLiteTelemetryRecorder 频繁 locked。差异在并发控制放在哪一层

HarnessLog(不锁死) SQLiteTelemetryRecorder(锁死)
连接 单长连接(构造建一次,check_same_thread=False,全程复用) 每次 _write 新建连接 + close
并发控制 进程内 threading.Lock 串行化写 无 Lock,靠 SQLite busy_timeout=5000 跨连接协调
结果 进程内始终只有一个连接写 → 零 SQLite 锁竞争 12-24 连接并发写同一 db → 高频撑爆 busy_timeout → locked

根因一句话telemetry 把并发控制交给 SQLite 的跨连接锁(busy_timeout,高频不可靠),HarnessLog 把它拉到进程内(threading.Lock,可靠串行)。telemetry 已有 WAL + busy_timeout + try/except 降级,但缺"单连接 + 进程内 Lock"这层,故高频下仍锁。

2. 为什么不直接用 HarnessLog(架构溯源)

telemetry 不能复用 HarnessLog 实例,三条硬隔离:

维度 telemetry HarnessLog
DB 文件 logs/telemetry.db(全局、跨 run/workspace workspaces/<ws>/harness.dbper-workspace 训练数据)
分层 adapters/(实现 core TelemetryRecorder Protocol app/harness/(应用层具体类)
职责/表 每次 LLM 调用 raw I/O 可观测性,llm_calls 训练数据(predictions/traces/gate),无 llm_calls

依赖方向禁止 adapters 依赖 app/harness 具体类(Clean Architecture)。telemetry 当初独立实现是架构正确的。

3. 决策:对齐同模式,不抽共享基座

真正的次优点是"单连接+Lock+WAL"这套可靠模式被重复实现HarnessLog 一份、telemetry 一份且写错)。理想是抽共享基座,但受阻:

  • 共享基座放 adapters/ → appHarnessLog)不依赖 adapters,用不了。
  • core/ → 违反"core 不含 SQLite 具体实现"。
  • 要抽须新开双方都能依赖的基础设施模块 + 改动刚改过 register_run 的 HarnessLog(回归风险)+ 改动面大。

决策(Rejected: 抽共享基座)telemetry 对齐 HarnessLog 已验证的连接管理模式,两处加交叉引用注释。接受"同一可靠模式在两处应用"(模式复用,非逻辑重复),换取改动局限、零风险、不动 HarnessLog。共享基座记 future work(若第三处再出现同款 SQLite 写需求,届时抽)。

4. 改动(局限 adapters/telemetry.py 一个类)

  • 构造 __init__:建一个长连接 sqlite3.connect(db_path, check_same_thread=False)asyncio.to_thread 在线程池不同线程调用,共享连接需此 flag + Lock 保证串行);PRAGMA journal_mode=WAL;建表一次(去掉懒建表 _ensure_table/_table_ready);建 threading.Lock
  • _write:改为 with self._lock: self._conn.execute(INSERT); self._conn.commit()(不再新建/关闭连接)。
  • 保留INSERT OR IGNOREcall_id 主键幂等)、try/except sqlite3.Error → 降级 warning(遥测失败绝不冒泡拖垮 LLM 调用)、async record_llm_callasyncio.to_thread 卸载阻塞写。
  • 注释telemetry.pylog.py 各加一行交叉引用,标注共用"单连接+Lock+WAL"并发写模式。

5. 前序版本行为审计

现有行为 处置
record_llm_call async Protocol 接口 保留GovernedLLMClient 依赖签名)
每次写新建连接 + close 替换(锁竞争根源)→ 单长连接
无进程内 Lock 新增 threading.Lock
asyncio.to_thread 卸载 保留Lock 线程内持有,串行化)
INSERT OR IGNORE(幂等) 保留
try/except 降级不冒泡 保留(核心哲学,遥测失败不拖垮主流程)
懒建表 替换为构造时建一次(长连接下无需懒建)
WAL + busy_timeout 保留 WALbusy_timeout 可保留(长连接下已无跨连接竞争,作纵深防御)

6. 非功能四维

维度 保障
持久化 每次 commit 同步落 WAL → 零丢失(满足硬约束)
幂等 INSERT OR IGNORE + call_id 主键,重复写安全
续跑 不适用(遥测无状态;进程退出 WAL 自动恢复已 commit 的)
原子性 单条 insert+commit 原子,无半写

零丢失保证的适用范围(Codex 审明确)

  • 单进程、单 recorder 实例:锁与连接是实例字段,串行化只在同一实例内成立。同进程多个 recorder 指向同一 db 会退回跨连接竞争——当前 main.py / video_split_cli 均单实例注入,不踩;本实现不支持多实例同库(YAGNI,若未来需要再引 class-level registry)。
  • 唯一 call_idINSERT OR IGNORE 下重复 call_id 是预期忽略(幂等),不计作丢失。

降级边界(Codex 审加固)__init__ 的 mkdir / connect / PRAGMA / 建表统一纳入 except (OSError, sqlite3.Error) 降级(self._conn=None),任一失败都不冒泡拖垮初始化;_writeself._conn is None 或 execute 抛错均降级 warning。守住"遥测失败绝不拖垮 LLM 调用"哲学。

生命周期:补幂等 close()(对齐 HarnessLog)供进程退出前可选调释放 fd;不调也不丢数据(WAL 已 commit)。telemetry 是长生命周期单例,无 context-manager 场景,故 close 为可选而非强制。

7. 测试

  • 并发写不锁死(核心):多线程/多协程并发调 record_llm_call(如 32 并发 × N 条),断言全部落库、零 database is locked、零丢失(行数 == 写入数)。这是复现 bug 的真实场景测试。
  • 幂等:同 call_id 重复写,只 1 行。
  • 降级不冒泡DB 错误(如目录不可写)时 record_llm_call 不抛,只 warning。
  • 现有 test_telemetry.py 回归全绿。