写一半崩了,已写的数据也不坏
append-only 账本不能只活在内存:每条事件先 enqueueLive 进 200ms 写后批量,persistContiguous 持 lease、校验连续、先修 torn-tail,再用 mkdir 0o700 + tmp wx 0o600 + link() 原子发布;崩溃后 SessionLogScanner 识别截断尾巴并 truncate 回最后一条完整事件。
Written in Chinese
前面几章把账本的内存侧讲完了:append-only、事件信封、表面投影、业务投影。但一本只活在内存里的账本,进程一退就归零。本章回答最后一个问题:这些事件怎么一行一行落到磁盘上,才能做到写一半崩了不损坏已写的数据、重启后能原样恢复? 这是整套 append-only 设计的地基——内存里再优雅,磁盘上一个半截文件就能让它全部作废。
上一章 第 25 章 拆了投影怎么随事件折叠。本章落到持久化:事件怎么成批写盘、怎么原子发布、怎么在崩溃后把尾巴修干净。
写后批量:不是每条事件都 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/event | enqueueLive:克隆入队,arm/复用 200ms timer |
| timer 到 | drainBuffered:整批 persistContiguous |
session/flush | 立即 drainLive + flush(不等 timer) |
session/disposed | close 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
}
逐道:
ensureLease——确保这个 session 的磁盘句柄由本进程独占持有,不让另一个进程或另一个 writer 同时写同一个文件。assertContiguous(id, batch, cursor)——校验这批事件的第一条 seq 必须恰好等于 cursor。账本是 append-only、seq 连续的,一旦中间出现空洞就是 bug,立刻抛错而不是写一个带洞的文件。- 先收拾 recoveredTail——如果上次崩溃时恢复出一段”磁盘上有、但内存 cursor 没承认”的尾巴,先把它重写、再清掉。
- 写新批——
persistBatch真正追加。 - 推进 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 文件身份完全相同才认为内容稳定,否则说明读到一半文件被换了,重读。
| 恢复步骤 | 动作 |
|---|---|
| readStoredLog | stat 五元组 → decodeStoredLog |
| decodeStoredLog | 把 buffer 截到 committedBytes(丢 torn tail) |
| truncateTornTail | 持久化截断(repair: truncate+sync) |
| readStableSnapshot | stat→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"