# 遥测 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/` → app（HarnessLog）不依赖 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 | 保留 WAL；busy_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` 回归全绿。
