Files
AgentCompany/docs/proposals/B4-single-writer-actor.md
T

13 KiB
Raw Blame History

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)

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 计划

  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(当前):

// 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 成本最低收益最高。