13 KiB
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 类:
- Scheduler 周期唤醒(
src/main/orchestration/scheduler.ts、scheduler-heartbeat.ts) - Task runner dispatch(
src/main/orchestration/task-runner.ts) - Heartbeat 周期写(
src/main/orchestration/heartbeat.ts的markHeartbeatAt) - Worker 回调 run 状态(
src/main/worker/worker-runner.ts) - Automation service 触发(
src/main/automation-service.ts) - 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。
这里有三个独立问题叠加:
- 没有 single-writer 约束:代码层面没人负责串行化写请求。
- 没有读写分离:读请求和写请求共用同一连接,读也在抢 writer lock。
- 没有重试 / 退避:事务失败直接抛,上层是否处理不确定(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)
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
// 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 计划
- Phase 1(1 周):新增 writer-actor.ts 和 reader-pool.ts,不改任何 call site。
- Phase 2(3 天):找出所有
db.prepare+.run的 mutation 调用(grep),按文件分批 migrate 到writerActor.enqueue。每批一个 PR,各跑一次 typecheck。 - Phase 3(2 天):把 read-heavy 的 service(dashboard / list view)切到 reader pool。
- Phase 4(1 天):打开 ESLint rule,禁止新代码绕过 actor。
- 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,不改事务语义。
验证标准
- 压力测试:启动 20 个 agent 同时跑 run,观察
writer_queue_depthp99 < 50 - SQLITE_BUSY 消失:生产 telemetry 跑一周,
SQLITE_BUSY计数应归零(因为不再有多 writer 抢锁) - 行为 eval(前置 B5):相同 workload 下,改造前后 run 完成率 / tool call 序列一致
- Deadlock 单测:actor 内部
enqueue嵌套直接抛错,不允许死锁发生 - 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(当前):
// 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:
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 成本最低收益最高。