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

82 lines
6.3 KiB
Markdown
Raw Permalink 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.
# 遥测 SQLite 高并发写锁修复设计
> 2026-07-16。修复 `SQLiteTelemetryRecorder` 在 concurrency≥12 时 `database is locked`、遥测大量丢失的问题。诉求:训练(concurrency 24)时遥测**零丢失**——保住"详细 agent 日志用于回溯分析"这一核心诉求。
## 1. 根因(对照 HarnessLog 暴露)
同样高并发写 SQLite`app/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.db`per-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 IGNORE`call_id 主键幂等)、`try/except sqlite3.Error → 降级 warning`(遥测失败绝不冒泡拖垮 LLM 调用)、`async record_llm_call``asyncio.to_thread` 卸载阻塞写。
- **注释**`telemetry.py``log.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_id**`INSERT OR IGNORE` 下重复 call_id 是**预期忽略**(幂等),不计作丢失。
**降级边界(Codex 审加固)**`__init__` 的 mkdir / connect / PRAGMA / 建表统一纳入 `except (OSError, sqlite3.Error)` 降级(`self._conn=None`),任一失败都不冒泡拖垮初始化;`_write``self._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` 回归全绿。