深入浅出 DeepSeek Harness 第四部:事件账本——Session是append-only日志 第 26 章

写一半崩了,已写的数据也不坏

append-only 账本不能只活在内存:每条事件先 enqueueLive 进 200ms 写后批量,persistContiguous 持 lease、校验连续、先修 torn-tail,再用 mkdir 0o700 + tmp wx 0o600 + link() 原子发布;崩溃后 SessionLogScanner 识别截断尾巴并 truncate 回最后一条完整事件。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

前面几章把账本的内存侧讲完了:append-only、事件信封、表面投影、业务投影。但一本只活在内存里的账本,进程一退就归零。本章回答最后一个问题:这些事件怎么一行一行落到磁盘上,才能做到写一半崩了不损坏已写的数据、重启后能原样恢复? 这是整套 append-only 设计的地基——内存里再优雅,磁盘上一个半截文件就能让它全部作废。

上一章 第 25 章 拆了投影怎么随事件折叠。本章落到持久化:事件怎么成批写盘、怎么原子发布、怎么在崩溃后把尾巴修干净。

jsonl-crash-recovery 机制图:左侧 session/event 进入 enqueueLive,structuredClone 进 buffered 队列并 arm 200ms timer;timer 到或 session/flush 触发 drainBuffered;中间 persistContiguous 流水线:ensureLease → assertContiguous(批次首 seq==cursor)→ 若有 recoveredTail 先重写修 torn-tail → persistBatch → 推进 cursor;右侧文件原子发布:mkdir 0o700 逐级 sync、内容写随机 tmp(open wx 0o600)、link()+unlink() 发布而非 rename;底部崩溃恢复:SessionLogScanner 把无换行结尾的半截记录判为 torn tail,decodeStoredLog 截回最后一条完整事件,rollbackAppend 把文件 truncate 回写前大小。

写后批量:不是每条事件都 fsync

直觉上,append 一条事件就该立刻写盘。但那样每个 turn 会触发十几次 fsync——一次 LLM 往返要发几十条事件,全部同步落盘会慢到没法用。Harness 的取舍是写后批量:

export const LIVE_WRITE_BATCH_MAX_DELAY_MS = 200

enqueueLive(event, reportBackgroundFailure): void {
  this.buffered.push(structuredClone(event))     // 克隆进待写队列
  if (this.batchTimer !== undefined || this.drainPaused) return
  this.batchTimer = setTimeout(() => { ...drainBuffered() }, LIVE_WRITE_BATCH_MAX_DELAY_MS)
}

两条规则:

  • 事件先进队列——structuredClone 一份进 buffered,避免内存里的事件对象被后续改动污染待写副本。
  • 最多等 200ms——第一条事件 arm 一个 200ms timer,期间进来的所有事件攒在同一批;timer 到了整批写一次。
时机动作
session/eventenqueueLive:克隆入队,arm/复用 200ms timer
timer 到drainBuffered:整批 persistContiguous
session/flush立即 drainLive + flush(不等 timer)
session/disposedclose writer

为什么是”批量 + 克隆”,而不是直接把内存对象引用写出去? 因为事件对象 append 之后还可能被读、被投影持有引用。如果写盘路径直接序列化原对象,一次并发改动就会把”已经决定落盘的内容”偷偷改掉——append 时承诺写的是 A,200ms 后落盘变成了被人改过的 B。structuredClone 在入队瞬间冻结一份快照,把”要写什么”和”内存里还在变什么”彻底切开。

本节源码依据(4 处)

persistContiguous:一道严格的写盘流水线

timer 到了,整批交给 persistContiguous。这不是一个 writeFile,而是一串有顺序的守卫:

private async persistContiguous(batch): Promise<void> {
  await this.ensureLease()                              // 1. 独占租约
  assertContiguous(this.id, batch, this.state.cursor)  // 2. 首 seq 必须接 cursor
  if (this.state.recoveredTail !== undefined) {        // 3. 上次崩溃的尾巴
    if (this.state.recoveredTail.length > 0)
      await this.storage.persistBatch(header, this.state.recoveredTail, ...)
    this.state.recoveredTail = undefined
  }
  await this.storage.persistBatch(header, batch, ...)    // 4. 写新批
  // 5. 推进 cursor
}

逐道:

  1. ensureLease——确保这个 session 的磁盘句柄由本进程独占持有,不让另一个进程或另一个 writer 同时写同一个文件。
  2. assertContiguous(id, batch, cursor)——校验这批事件的第一条 seq 必须恰好等于 cursor。账本是 append-only、seq 连续的,一旦中间出现空洞就是 bug,立刻抛错而不是写一个带洞的文件。
  3. 先收拾 recoveredTail——如果上次崩溃时恢复出一段”磁盘上有、但内存 cursor 没承认”的尾巴,先把它重写、再清掉。
  4. 写新批——persistBatch 真正追加。
  5. 推进 cursor——只有写成功了才动 cursor,写失败 cursor 不动,下次重试。

写失败时的策略也很关键(在 drainBuffered 里):

catch (error) {
  await this.storage.persistBatch(batch)   // 失败
  this.buffered = batch.concat(this.buffered)  // 整批塞回队首
  this.drainPaused = true                     // 暂停后续 drain
}
阶段守卫/动作失败后果
ensureLease独占租约拿不到即不写
assertContiguous首 seq==cursor抛错,不写带洞文件
recoveredTail先重写上次崩溃尾巴清掉 recoveredTail
persistBatch追加本批整批回队首 + drainPaused

为什么写失败要”整批回队首 + drainPaused”,而不是丢掉或硬塞? 因为 append-only 账本的铁律是”一条都不能少”。一次 fsync 失败可能只是磁盘瞬时繁忙,丢掉这批就是永久丢失 N 条事件,模型历史就缺了一块。把这批放回队首最前面、暂停后续写入,等于把流量收住,等 session/flush 显式重试——宁可卡住,也不丢事件。

本节源码依据(5 处)

POSIX 原子发布:tmp + link(),不用 rename()

落盘那一步本身也有讲究。建新 session 文件时(materializePosix):

private async materializePosix(project, dir, finalPath, id, content) {
  await mkdir(this.root, { recursive: true, mode: 0o700 })   // 逐级 0700
  await this.syncDirPosix(dirname(this.root))
  // ...逐级 mkdir + syncDirPosix
  const tmp = await this.writeSyncedTempFile(finalPath, content)
  await link(tmp, finalPath)      // 发布:link 而非 rename
  await this.syncDirPosix(dir)    // fsync 目录让新文件名落地
}

private async writeSyncedTempFile(finalPath, content) {
  const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
  const handle = await open(tmp, 'wx', 0o600)   // wx:存在即失败
  // 写内容 + fsync
}

四个细节:

  • 目录 0o700、文件 0o600——session 日志是隐私,权限压到最严。
  • 逐级 mkdir + syncDirPosix——每建一级目录就 fsync,保证目录结构断电后真的存在。
  • open(tmp, 'wx', 0o600)——wx 是”独占创建”:tmp 文件已存在就直接报错。配上随机后缀,天然防撞名。
  • link(tmp, finalPath) 而不是 rename——注释明说:link 若目标已存在会 EEXIST,拒绝覆盖。rename 会静默覆盖已有文件,并发下可能把另一个 writer 刚写好的日志顶掉。

追加写(appendLines)的崩溃保护:

private async appendLines(meta, events) {
  const before = (await stat(path)).size
  try { await handle.appendFile(...) }
  catch { await this.rollbackAppend(path, before) }   // 回滚到写前大小
}
private async rollbackAppend(path, size) {
  await handle.truncate(size)   // truncate 回写前大小
}
操作原子手段防的是什么
新建文件tmp wx + link() + unlink()并发覆盖已有日志
追加事件stat before → append → 失败 rollback truncate写一半崩留下半截
目录结构逐级 mkdir 0o700 + syncDir断电后目录不存在

为什么”用 link 不用 rename”这么较真? 因为这是一个多进程、可能多 writer 的系统。rename 是”无条件移动”,两个进程同时 publish 同一个 finalPath,后到的静默顶掉先到的——一个本应持久化的日志就被无声替换了。link 是”只有目标不存在才成功”,把”覆盖”从隐式行为变成显式错误,并发冲突立刻暴露而不是悄悄吞掉。

本节源码依据(6 处)

崩溃恢复:识别并修掉 torn tail

重启后读旧日志,最大的风险是:文件最后一条事件可能只写了一半——fsync 到一半进程被杀,磁盘上留着一条没写完 JSON 的残行。

SessionLogScanner 增量扫描 JSONL,规则很朴素:

  • 每条事件是一行以换行结尾的 JSON。
  • 扫描器维护 committedBytes:只有扫到一条完整(换行结尾、JSON 可解析)的记录,才把已提交字节数推进到它的末尾。
  • 最后一段没有换行结尾、或解析失败的尾巴,就是 torn tail——直接忽略它。
class SessionLogScanner {
  private committedBytes: number
  finish(): Snapshot {            // 收尾时把最后一条无换行记录当 torn tail 丢掉
    return { committedBytes: this.committedBytes, ... }
  }
}

decodeStoredLog 据此把 buffer 截到 committedBytes;需要持久化这个截断时,truncateTornTail → repair 把文件真正 truncate 回最后一条完整事件之后。

读取时还有一层”身份校验”(readStoredLog):先 stat 拿 dev/ino/size/mtimeNs/ctimeNs 五元组,读内容,恢复后再用这套身份判断文件有没有被并发替换。快照读更严格——readStableSnapshot 做 stat → readFile → 再 stat,两次 stat 文件身份完全相同才认为内容稳定,否则说明读到一半文件被换了,重读。

恢复步骤动作
readStoredLogstat 五元组 → decodeStoredLog
decodeStoredLog把 buffer 截到 committedBytes(丢 torn tail)
truncateTornTail持久化截断(repair: truncate+sync)
readStableSnapshotstat→read→stat,两次身份相同才返回

读进来之前还会做格式守门:refuseForeignFormatVersion 拒认格式版本对不上的日志,assertNoRetiredHeaderFields 拒绝已废弃的 header 字段(如旧版的 sandboxMode/approvalPolicy)——宁可拒绝加载,也不把一份过时代码写出的日志错误地折成新格式。

为什么”半截记录就直接丢掉”是安全的? 因为 append-only 账本里,最后一条半截事件必然是某次尚未被承认的写入。它对应的内存事件要么还在 buffered 队列里没被 fsync,要么进程已经崩了——总之它从来没有被 cursor 正式承认过。丢掉一个从没生效过的尾巴,等于”当这次写入没发生过”,账本依然连续、依然自洽。这就是 append-only + seq 连续给崩溃恢复买的保险:你永远不需要去”修复”一条已提交的事件,因为真正提交的那些都完整地躺在换行边界之前。

本节源码依据(7 处)

小结

  • 写后批量:事件 enqueueLive 克隆入队,200ms 攒一批再写,不为每条事件付一次 fsync。
  • 严格写盘流水线:ensureLease → assertContiguous → 先收拾 recoveredTail → persistBatch → 推进 cursor。
  • POSIX 原子发布:目录 0o700、tmp wx 0o600、link()+unlink() 拒绝并发覆盖;追加失败 rollback 回写前大小。
  • 崩溃恢复:SessionLogScanner 把无换行的半截尾巴判为 torn tail,截回最后一条完整事件;格式守门拒认过时代码。
  • 可重放即恢复:丢掉未生效的尾巴不破坏连续 seq,因为真正提交的事件都完整躺在换行边界之前。

下一章:第 27 章

只读验证

# 验证源码身份(应输出 477b4f420553e8a52c2fbccc464d7561b239c443)
git -C "$DSH_SOURCE_DIR" rev-parse HEAD

# 逐字核对本章引用的持久化常量与守卫
grep -n "LIVE_WRITE_BATCH_MAX_DELAY_MS = 200" "$DSH_SOURCE_DIR/packages/session/session-persistence-jsonl/src/storage.ts"
grep -n "link" "$DSH_SOURCE_DIR/packages/session/session-persistence-jsonl/src/index.ts" | grep -i rename
grep -n "refuseForeignFormatVersion" "$DSH_SOURCE_DIR/packages/session/session-persistence-jsonl/src/format.ts"