253 lines
13 KiB
Markdown
253 lines
13 KiB
Markdown
# Proposal B4: 所有 mutation 集中到 single-writer actor(main 进程串行 queue)
|
||
|
||
> 来源:问题.md B4(行 83-94)、附录 G 第 5 行(行 559)
|
||
> 状态:proposed(待 B2 完成后实施)
|
||
> 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs)
|
||
> Owner: TBD
|
||
> 估计工时:2 周
|
||
> Blocking dependencies:B2(phase/lease 重构先稳住数据结构)
|
||
|
||
## 问题陈述
|
||
|
||
项目只有一个 `DatabaseSync` 连接(`src/main/database-base.ts` 的 `openConnection` 在 main 进程初始化一次),但并发 writer 至少有 4 类:
|
||
|
||
1. **Scheduler 周期唤醒**(`src/main/orchestration/scheduler.ts`、`scheduler-heartbeat.ts`)
|
||
2. **Task runner dispatch**(`src/main/orchestration/task-runner.ts`)
|
||
3. **Heartbeat 周期写**(`src/main/orchestration/heartbeat.ts` 的 `markHeartbeatAt`)
|
||
4. **Worker 回调 run 状态**(`src/main/worker/worker-runner.ts`)
|
||
5. **Automation service 触发**(`src/main/automation-service.ts`)
|
||
6. **Backup 任务**(`src/main/database-backup.ts` 或类似)
|
||
|
||
所有 writer 都走 `transaction(BEGIN IMMEDIATE)`。`src/main/database-base.ts:433-440` 的连接配置只有 `busy_timeout=5000`,`database-base.ts:442-452` 的 `transaction` 包装**没有任何重试**。
|
||
|
||
后果:
|
||
- 高压场景下 `BEGIN IMMEDIATE` 会互相串行等 5 秒
|
||
- 一旦串行等待链超过 5 秒,事务被 `SQLITE_BUSY` 反弹
|
||
- 对调用方:可能表现为"事务成功"但实际回滚,或抛 raw `SQLITE_BUSY` 没人 catch
|
||
- 线上没有 `SQLITE_BUSY` 计数 telemetry,问题完全不可观测
|
||
|
||
## 根因分析
|
||
|
||
Linus 视角:**SQLite 不是并发数据库**。WAL 模式下读可以并发,写永远只有一个。这不是 bug,是 SQLite 的 design goal。
|
||
|
||
当前项目的错误是:**把 SQLite 当 Postgres 用**——4+ 个异步来源各自 `BEGIN IMMEDIATE`,期望 SQLite 帮你协调。SQLite 的"协调"机制就是让你排队等 5 秒然后报错。`busy_timeout` 不是并发控制,它只是延后 crash。
|
||
|
||
这里有三个独立问题叠加:
|
||
|
||
1. **没有 single-writer 约束**:代码层面没人负责串行化写请求。
|
||
2. **没有读写分离**:读请求和写请求共用同一连接,读也在抢 writer lock。
|
||
3. **没有重试 / 退避**:事务失败直接抛,上层是否处理不确定(B4 和 C6 的 silent catch 问题叠加)。
|
||
|
||
Karpathy 视角补充:agent 规模一大(10+ agent concurrent)就会被这个问题困住——不是功能不够,是基础设施撑不住。而且这种问题**在 dev 环境永远复现不了**,因为 dev 只有 1-2 个 agent,排队等 5 秒根本触达不到。
|
||
|
||
## 设计方案
|
||
|
||
### 架构:Writer Actor + Reader Pool
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────┐
|
||
│ Main process │
|
||
│ │
|
||
│ ┌──────────────┐ ┌────────────────────┐ │
|
||
│ │ WriterActor │◄───│ enqueueWrite(fn) │ │
|
||
│ │ (async │ │ from any caller │ │
|
||
│ │ queue) │ └────────────────────┘ │
|
||
│ │ │ │
|
||
│ │ single │◄── scheduler / heartbeat / │
|
||
│ │ writer │ worker / automation / … │
|
||
│ │ connection │ │
|
||
│ └──────┬───────┘ │
|
||
│ │ │
|
||
│ ┌──────▼───────────┐ │
|
||
│ │ SQLite (WAL) │ │
|
||
│ └──────▲───────────┘ │
|
||
│ │ │
|
||
│ ┌──────┴───────────┐ │
|
||
│ │ ReaderPool │ │
|
||
│ │ (readonly: true │ │
|
||
│ │ connections) │ │
|
||
│ └──────────────────┘ │
|
||
└─────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 实现
|
||
|
||
**1. WriterActor(新文件 `src/main/database/writer-actor.ts`)**
|
||
|
||
```ts
|
||
type WriteJob<T> = () => T;
|
||
|
||
class WriterActor {
|
||
private queue: Array<{ job: WriteJob<unknown>; resolve: (v: unknown) => void; reject: (e: unknown) => void }> = [];
|
||
private running = false;
|
||
private db: DatabaseSync; // 独占的 writer 连接
|
||
|
||
async enqueue<T>(job: WriteJob<T>): Promise<T> {
|
||
return new Promise((resolve, reject) => {
|
||
this.queue.push({ job: job as WriteJob<unknown>, resolve: resolve as (v: unknown) => void, reject });
|
||
if (!this.running) this.drain();
|
||
});
|
||
}
|
||
|
||
private drain() {
|
||
this.running = true;
|
||
while (this.queue.length > 0) {
|
||
const { job, resolve, reject } = this.queue.shift()!;
|
||
try {
|
||
// 单连接独占,不需要 BEGIN IMMEDIATE 抢锁
|
||
resolve(this.db.transaction(job)());
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
}
|
||
this.running = false;
|
||
}
|
||
}
|
||
```
|
||
|
||
**关键语义**:
|
||
- 写永远串行(actor 内部 FIFO queue)
|
||
- 写不再等 busy_timeout——因为没人和它抢
|
||
- 任何 writer 都必须走 `writerActor.enqueue(() => db.transaction(...))`
|
||
|
||
**2. ReaderPool**
|
||
|
||
```ts
|
||
// src/main/database/reader-pool.ts
|
||
export function openReader(dbPath: string): DatabaseSync {
|
||
return new DatabaseSync(dbPath, { readOnly: true });
|
||
}
|
||
```
|
||
|
||
Readers 走独立 connection,`readOnly: true`。N 个 reader 可以并发读(WAL 保证)。
|
||
|
||
**3. 改造所有 mutation call sites**
|
||
|
||
| 文件 | 当前写法 | 改造后 |
|
||
|------|---------|--------|
|
||
| `src/main/orchestration/scheduler.ts` | `db.transaction(...)` | `writerActor.enqueue(() => ...)` |
|
||
| `src/main/orchestration/heartbeat.ts` | `db.run("update ...")` | `writerActor.enqueue(() => db.run(...))` |
|
||
| `src/main/worker/worker-runner.ts` | 回调直接写 | `writerActor.enqueue(...)` |
|
||
| `src/main/automation-service.ts` | rule fire 写 db | `writerActor.enqueue(...)` |
|
||
| `src/main/database-base.ts:442-452` | `transaction(fn)` | 保留但改为 reader 专用,writer 入口走 actor |
|
||
|
||
**4. ESLint rule 强制**
|
||
|
||
自定义规则:`no-direct-db-write`——禁止在非 `writer-actor.ts` 文件中调用 `db.prepare(...).run(...)`(除非 `readonly: true` 明确标注)。
|
||
|
||
### Backpressure
|
||
|
||
队列长度超过阈值(例如 100)时:
|
||
- 记 telemetry `writer_queue_depth`
|
||
- 超过 1000 直接拒绝新入队,抛 `WriterBusyError`
|
||
- 让上游(scheduler / worker)自己决定是否降频
|
||
|
||
## Migration 计划
|
||
|
||
1. **Phase 1(1 周)**:新增 writer-actor.ts 和 reader-pool.ts,不改任何 call site。
|
||
2. **Phase 2(3 天)**:找出所有 `db.prepare` + `.run` 的 mutation 调用(grep),按文件分批 migrate 到 `writerActor.enqueue`。每批一个 PR,各跑一次 typecheck。
|
||
3. **Phase 3(2 天)**:把 read-heavy 的 service(dashboard / list view)切到 reader pool。
|
||
4. **Phase 4(1 天)**:打开 ESLint rule,禁止新代码绕过 actor。
|
||
5. **Phase 5(持续监测)**:telemetry 观察 writer queue depth p99,1 周后把 busy_timeout 下调到 500ms(writer actor 内部串行后根本不该触达)。
|
||
|
||
不需要 schema migration——这是纯代码结构重构。
|
||
|
||
## 风险与回滚
|
||
|
||
| 风险 | 触发条件 | 回滚方法 |
|
||
|------|---------|---------|
|
||
| Deadlock | actor 内部 job 等待另一个 actor job | 禁止 job 内再调 `enqueue`(ESLint rule + runtime 检查) |
|
||
| 吞吐下降 | 串行化本来并行的写 | SQLite 写本来就是串行的,只是从"互等 5 秒"变成"排队",p99 延迟会降不会升 |
|
||
| Queue 堆积 | 某个 slow query 堵住 actor | backpressure + telemetry 告警 |
|
||
| Reader 读到 stale | WAL 下 reader 看到的是自己 snapshot 的数据 | 读写分离的固有语义,UI 层通过 IPC event 推送更新 |
|
||
|
||
回滚路径:任何阶段发现问题,把 `writerActor.enqueue(fn)` 改回 `fn()` 直接跑——actor 只是 queue,不改事务语义。
|
||
|
||
## 验证标准
|
||
|
||
1. **压力测试**:启动 20 个 agent 同时跑 run,观察 `writer_queue_depth` p99 < 50
|
||
2. **SQLITE_BUSY 消失**:生产 telemetry 跑一周,`SQLITE_BUSY` 计数应归零(因为不再有多 writer 抢锁)
|
||
3. **行为 eval**(前置 B5):相同 workload 下,改造前后 run 完成率 / tool call 序列一致
|
||
4. **Deadlock 单测**:actor 内部 `enqueue` 嵌套直接抛错,不允许死锁发生
|
||
5. **Reader 隔离**:kill writer actor 期间,reader 仍能正常服务 read-only IPC
|
||
|
||
## 不在范围
|
||
|
||
- **不改 SQLite 版本或 WAL 参数**:现有 WAL + checkpoint 策略保留。
|
||
- **不引入 Postgres / 分布式数据库**:SQLite 单机单 writer 是 desktop app 的合理选择。
|
||
- **不做 per-company 分片**:单 actor 足够处理单 desktop 的量,分片是未来的事。
|
||
- **不重写 transaction 语义**:`db.transaction(fn)()` 包装保留,只是入口走 actor。
|
||
- **不改 IPC**:renderer 还是通过 IPC 触发 mutation,只是 handler 内部走 actor。
|
||
|
||
## 参考文件清单
|
||
|
||
- `src/main/database-base.ts:433-440`(连接配置 busy_timeout=5000)
|
||
- `src/main/database-base.ts:442-452`(transaction 无重试)
|
||
- `src/main/orchestration/scheduler.ts`(wake writer)
|
||
- `src/main/orchestration/scheduler-heartbeat.ts`(heartbeat writer)
|
||
- `src/main/orchestration/task-runner.ts`(dispatch writer)
|
||
- `src/main/orchestration/heartbeat.ts`(markHeartbeatAt writer)
|
||
- `src/main/worker/worker-runner.ts`(run status writer)
|
||
- `src/main/automation-service.ts`(rule fire writer)
|
||
|
||
## 附:为什么现在修
|
||
|
||
**现在**的触发器是 company 规模——10+ agent 并发运行时 SQLite 写队列 5 秒 busy_timeout 很容易击穿。此时 transaction rollback 对 caller 表现为"我写成功了",但数据库里没变化——这种 **silent data loss** 是生产事故的标准剧本。
|
||
|
||
团队一旦有第一个「我明明 save 了啊」的用户工单,排查成本是正式修一次的 5 倍:要查 log、查 SQLITE_BUSY、复现并发条件。不如现在一次性把并发模型收敛掉。
|
||
|
||
B4 同时是 B2 的防线——B2 的 phase/lease 重构把 tasks 写路径集中了,但其他表(runs / agents / approvals)的写路径如果还散装,多 writer 竞态就会在那里继续发作。
|
||
|
||
## 附:反模式对比
|
||
|
||
**Before**(当前):
|
||
```ts
|
||
// src/main/orchestration/heartbeat.ts(示意)
|
||
db.prepare("UPDATE runs SET last_heartbeat_at = ? WHERE id = ?").run(now, runId);
|
||
// 这条语句在 worker 并发写 runs 表时会抢 SQLite writer lock,busy_timeout=5000 内没抢到就抛
|
||
```
|
||
|
||
**After**:
|
||
```ts
|
||
await writerActor.enqueue(() => {
|
||
db.prepare("UPDATE runs SET last_heartbeat_at = ? WHERE id = ?").run(now, runId);
|
||
});
|
||
// 串行入队——永远不抢锁;latency = queue wait + 单次 SQL 时间
|
||
```
|
||
|
||
## 附:gating matrix
|
||
|
||
| Gate | 条件 | 放行判断人 |
|
||
|------|------|----------|
|
||
| G1 | B2 phase/lease 已上线,tasks 写路径已收敛 | Owner |
|
||
| G2 | Writer actor 跑 stress test:1000 concurrent enqueue,0 失败 | Linus |
|
||
| G3 | ESLint rule 运行后,grep `db.prepare.*\.run` 只出现在 writer-actor.ts 和 reader-pool.ts | Linus |
|
||
| G4 | 生产 telemetry `writer_queue_depth` p99 < 50 连续 7 天 | Karpathy |
|
||
| G5 | `SQLITE_BUSY` 出现次数降到 0 连续 14 天 | Owner |
|
||
|
||
## 附:backpressure 和降级策略
|
||
|
||
队列深度观察区间:
|
||
|
||
| 深度范围 | 行为 | telemetry |
|
||
|---------|------|----------|
|
||
| 0-10 | 正常 | 采样 1% 记录 |
|
||
| 11-50 | 正常但记录 | 100% 记录 queue_depth |
|
||
| 51-100 | 触发 warn | telemetry.alert_warn |
|
||
| 101-500 | 触发 degraded 模式——scheduler 降频 | telemetry.alert_error |
|
||
| >500 | 拒绝新入队(throw WriterBusyError) | telemetry.alert_critical |
|
||
|
||
降级模式下 scheduler 的 wake 频率从 1s 降到 5s,给 actor 喘息空间。恢复由 queue_depth < 30 自动触发。
|
||
|
||
## 附:为什么不选其他方案
|
||
|
||
**方案 A(当前 proposal)**:Single-writer actor in-process queue。优点:实现最简;缺点:所有写串行,p99 有队列 latency。
|
||
|
||
**方案 B(readonly connection pool + write connection pool 各自有锁)**:复杂度高;SQLite 多 writer 连接仍然只有一个真 writer,pool 意义不大。
|
||
|
||
**方案 C(每个 service 自己带 queue)**:控制分散;依然要协调多 queue 之间的依赖,复杂度翻倍。
|
||
|
||
**方案 D(换 Postgres)**:超出 scope;desktop 单机不需要外部 DB。
|
||
|
||
方案 A 成本最低收益最高。
|