2026-09-23·by Sijie Wang#lucerna#software#architecture

offline-first-outbox

离线优先同步:事务性 Outbox(桌面端)

父节点:key-designs

这是桌面端架构中最难啃的一块。Web 端是在线优先(纯 HTTP 委托 + 只读的 wa-sqlite 缓存,没有 outbox 机制);桌面端对已上线的 6 个领域(书籍/书签/笔记/词汇/偏好设置/复习)是真正意义上的离线优先,靠一层 Hybrid*Repository 包装器实现。

底层:经由 IPC 的原生 SQLite。 桌面端在 Electron 主进程里用的是 better-sqlite3main/sqlite-handler.ts,WAL 模式 + busy_timeout=30000),而不是 wa-sqlite——渲染进程这一侧的门面(ElectronSqliteHost)把所有操作串成一条单一的 Promise 链,并把 transaction(fn) 映射到一套 IPC 的 txBegin/txExec/txCommit/txRollback 协议上。因为 better-sqlite3 本身是同步的,而渲染进程的事务回调要跨越异步的 IPC 往返,它没法直接用 db.transaction(fn);取而代之的是一座事务 ID 桥:先发出 BEGIN,拿到一个 id,后续调用都挂在这个 id 下执行。一条 LOAD_TOKEN 水位线加上 resetPendingTransactions(),一起拆掉了"重载留下事务孤儿"这个死锁(也就是那个"reader 卡在 Loading book…"的 bug)。

权威来源 = 云端权威,清空重灌。 读取走的是"先 HTTP、后回填本地":seedLocal 会先执行 DELETE … WHERE id NOT LIKE 'temp_%',再在每次拉取列表时重新插入服务器返回的行(这样跨设备的服务器端删除就不会滞留);尚未同步的 temp_% 行则会保留下来。整个 FULL_SCHEMA_SQL 在每次启动时都会重新应用一遍。代码里的注释写道:"云端才是事实来源;本地缓存会在下一次会话时重新填充。"

写入路径(以 vocabulary.create 为例追踪):

  1. 优先在线——先尝试 vocabularyApi.create;成功后只需把这一行原样镜像到本地(这样联网时就不会出现临时 id 与服务器 id 之间的落差)。
  2. 离线兜底——铸造一个 temp_${uuid},并在同一个事务里既插入领域数据行、追加 outbox 条目(insertEntry + Outbox.appendInTx)。同一事务的原子性正是关键:如果崩溃发生在这两步之间,就会留下一条永远同步不上去的本地行,让这台设备永久失步。
  3. 触发一次 drain(排空)。
  4. DrainOutboxProcessor)——按最旧优先的顺序、合并处理,跳过尝试次数超过 MAX_ATTEMPTS=5 的条目;成功则 markDone(DELETE),失败则 markFailed(attempts 加一、记下 last_error)。触发时机:online 事件、每次写入之后,以及启动时延迟 2 秒触发一次(不设周期性定时器,以保护首屏渲染速度)。
  5. 对账(Reconcile)——create 处理器重放那次 POST 请求,然后执行 UPDATE … SET id=server_id WHERE id=tempId:临时行被原地改写为服务器身份。

冲突模型 = 最后写入者获胜 + 服务器端幂等,客户端不做合并Outbox 里的说明是:"不做去重,不做压缩,不保证乱序排空……由服务器对重复的 (bookId, page, label) 保证幂等")。SRS 在客户端跑——它归服务器所有(vocabulary-loop);SRS 相关的列被有意地从本地镜像里排除。

老实说这块还很糙(代码里也坦承这一点):每一行 hybrid 数据都用字面量 'local' 作为用户(没有按用户隔离——靠 clearHybridLocalState() 在会话切换时清空数据来掩盖跨用户泄漏的问题),而 library 这一块目前只走 HTTP(配额、本地迁移功能都被推迟了)。真正承重、并且有 E2E 测试覆盖的是:同事务的 outbox 原子性、清空重灌式读取、事务 ID 桥,以及临时 id 的对账重写。

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

offline-first-outbox