Files
PolyLoop/research-wiki/design/0011-jsonl-run-store.md
T
iomgaa 6af289d283 feat(stores): 落成逐行追加的日志存储,契约套件第一次真的在跑
design 0011(待确认)定了六条:一次运行一个文件且文件名就是运行标识(不转义不哈希,按标识
去目录里找文件是最自然的用法;标识必须是安全文件名,否则 ../ 会把文件写到目录外面);一行
一条记录加一个 record 类型标签(serialization 编出来的载荷没有元信息键,标签是存储这层加的,
record 从此是保留键);第一条解不开的行就是日志结尾、它后面还有内容就是损坏;fsync 只在
运行开始、两条意图、运行结束四处(其余两处靠前缀持久性兜);写入走 to_thread;运行开始记录
用 O_EXCL 兜住跨进程撞车。

契约套件里那条 test_step_without_an_action_is_still_recorded 转成真断言——它标着 xfail 的
理由是「StepCompleted.result_id 在 0006 里是必填字符串」,而 0006 决策七早就把它改成可为空
并加了不变量。xfail 8→7,跳过 24→14。

原子写「一起不可见」那一半按契约套件的点名在这一层补上了:给实现留一个可注入的故障点
(一个可替换的「把这些字节写进去」),测试把它换成写一半就抛异常,断言那条记录整条不可见。
前缀持久性仍然验不了(掉电才看得出来),继续登记为已知缺口。

调研三条实据写进了 0011:两个下游 fsync 全仓零处(一个的 SQLite 还开着 synchronous=NORMAL),
所以这条比它们都严、代价是每步两次 fsync;一个下游的轨迹检查器同样是「碰到第一条坏行就放弃
整个文件」;另一个下游踩过「文件名少一维导致两个阶段静默互相覆盖」,O_EXCL 把那类静默覆盖
变成显式失败。这几条我自己逐条核过——那份调研的 subagent 承认它编过一句「我抽查过了」。

migrations/dissect.md 登记两条:运行标识要带齐现在文件名里那五维,以及这份意图日志和它那份
逐步轨迹是两样东西不要混。
2026-08-10 03:38:31 -04:00

163 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design 0011 · 逐行追加的日志存储
**日期** 2026-08-10 · **状态** 待确认
**落实** `0003-public-api-shape.md` 决策四与 `0005-storage-atomicity-and-record-fields.md`
决策二、五。那两份定了存储接缝有哪六个方法、写入粒度是什么、哪两次写是耐久屏障、以及前缀
持久性这条要求;本文定**第一个真实实现**怎么满足它们。
**触及** `../../src/polyloop/stores/`,以及 `../../tests/contract/` 那套套件——它现在 24 条
全跳过,因为没有实现可接。
## 这份实现为什么值得一份 design doc
它写下去的东西**会被下游直接读**。某个下游的分析方式是让模型去翻文件,那份日志离开这个库
也得读得懂;文件名、行的形状、坏行怎么算,一旦有人的分析代码依赖上就改不动了。
它还是**唯一一处能验原子写与前缀持久性的地方**。契约套件明写这两条它验不了(要在写入中途
杀进程,而套件跑在一个进程里),并且点名「落地时要在 `stores` 的 unit 测试里用可注入的故障点
覆盖」。那个故障点的形状是本文要定的。
## 决策一:一次运行一个文件,文件名就是运行标识
`<目录>/<run_id>.jsonl`
**不做成一个大文件加运行标识列。** 一个大文件上,「读回某一次运行的整份日志」要扫全文,而
`read_log` 在每次 `run` 开工前都会被调用一次;更要命的是两次并发运行会往同一个文件追加,
前缀持久性从「同一文件的追加序」退化成「两条交错的序」,`0005` 决策五那条论证就不成立了。
**运行标识必须是一个安全的文件名,不合就报错,不做转义。** 判据是 `[A-Za-z0-9._-]+` 且不以
点开头。转义(百分号编码、哈希)能接受任意标识,但那样文件名就不再等于运行标识,而下游按
运行标识去目录里找文件是最自然的用法——`0005` 那句「那份文件离开数据库也得能读懂」说的正是
这种用法。报错的代价是调用方要约束自己的标识格式,那是一行校验;转义的代价是从此有两套标识
互相翻译。
**这条校验挡住的不只是可读性。** 运行标识是调用方给的不透明字符串,里面出现 `../` 或者绝对
路径的话,写文件会跑到目录外面去。
**迁移注意:运行标识要带齐能唯一定位这次运行的每一维。** 某个下游今天的轨迹文件名是
`r{轮次}__{阶段}__{题目}__s{种子}__a{尝试}.jsonl` 五维拼出来的,而且它的注释记了一个踩过的
坑——阶段那一维原先漏了,导致同一次 run 先后两个阶段的轨迹静默互相覆盖。它迁过来时那五维要
拼进运行标识,否则同样的覆盖会以「日志里有别人的记录」的形式重演。这条登记进
`../migrations/dissect.md`
## 决策二:一行一条记录,行首加一个类型标签
```json
{"record": "intent", "run_id": "r1", "kind": "model_call", ...}
```
`polyloop.serialization` 编出来的载荷**只有记录类自己的字段,没有任何元信息键**——那是刻意
的,为的是一条步记录的载荷和迁移前那份逐行轨迹同形。哪一行是哪种记录由存储自己解决,所以
标签是这一层加的。
**键名 `record` 从此是保留键。** 五个记录类现在都没有叫这个名字的字段,将来也不许加——加了
的话,编码出来的字典会和标签撞,而撞的表现是解码时把一条记录读成另一种。这条约束写在
`stores` 的模块 docstring 与 `serialization` 的编码说明里。
**取值就是记录类名的蛇形写法**`run_started` / `intent` / `model_call_result` /
`step_completed` / `run_finished`),不另起一套短名。短名省的那几个字节抵不上「查一个名字要
先查一张对照表」的成本。
## 决策三:撕裂的尾行丢掉,中间的坏行是损坏
按行扫,**第一条解不开的行就是日志的结尾**;如果它后面还有解得开的行,那不是撕裂而是损坏,
直接报错。
**为什么尾行可以丢。** 进程被杀在一次 `write` 中途,文件末尾会留下半行。那半行对应的那次
写入从来没有被确认过——调用方还没等到那个 `await` 返回,所以按契约它就是「没发生」。丢掉它
正是「要么都可见、要么都不可见」的落地方式。
**为什么中间的坏行不能丢。** 追加写只在末尾产生撕裂;中间出现读不了的字节意味着别的东西
(写坏、外部改动、两个进程交错写)动过这个文件。这时候跳过那一行接着读,会拼出一份少了几条
记录但看起来完整的日志,而恢复会照它做判断。`0002` 决策二那条「读到说不通的状态就失败,不
修复也不带着它继续」在这里同样适用。
**「碰到坏行就放弃这个文件」和某个下游今天的做法一致**:它的轨迹检查器把整个文件的读取包在
一个 `except (json.JSONDecodeError, UnicodeDecodeError)` 里,撞到第一条坏行就把整个文件降级成
一条违规,而不是跳过坏行接着读;它还有一条专门造「轨迹文件被截断成半行」的测试。这条不是我们
新发明的谨慎。
**空行跳过,不算坏行。** 它不携带任何记录,也不是撕裂的证据。
## 决策四:`fsync` 在三处,其余三处不做
| 写什么 | `fsync` | 为什么 |
|---|---|---|
| 运行开始 | 是 | 它是整份日志的头,丢了就读不出这次运行按哪份配置跑 |
| 两条意图 | 是 | `0003` 决策四定的耐久屏障:必须落盘才能发出调用 / 执行动作 |
| 模型调用结果 | 否 | 后面紧跟的不是副作用 |
| 动作结果与步记录 | 否 | 同上 |
| 运行结束 | 是 | 丢了的话这次运行看起来还能续,而它已经跑完了 |
**不做 `fsync` 的那两次靠前缀持久性兜。** 同一个文件的追加写,后一次 `fsync` 会把它之前的
全部内容一起刷下去,所以「模型调用结果还没落盘,动作意图(屏障)落了盘」这个状态在这份实现
上不可能出现——`0005` 决策五要求的正是这个,而这份实现是天然满足的那一类。
**每次写都是「打开、追加、按需 `fsync`、关闭」,不长期持有文件句柄。** 持有句柄要为每个运行
标识维护一份状态,而那份状态在并发下就是共享可变状态;打开的成本相对于一次 `fsync` 可以忽略,
而一次 `fsync` 相对于一次模型调用又可以忽略。
**这条比两个下游今天的做法都严,代价要认下。** 它们的仓库里 `fsync` 一处都没有:一个的轨迹
文件是整体写完再关,它的 SQLite 开着 `synchronous = NORMAL`(WAL 下不对每次提交刷盘);另一个
的「原子写」是临时文件加 `rename`,关文件之前不 `fsync``rename` 之后也不 `fsync` 父目录,
所以它只保证不出现半截文件,不保证掉电后内容还在。严这一档的直接代价是每一步多两次 `fsync`
——一次几万步的批跑要多花几秒到几十秒,相对于同一批里几万次模型调用可以忽略。
**不提供「关掉 `fsync`」的开关。** 关掉之后耐久屏障就不成立了,而恢复的全部正确性建立在它
上面;一个能把正确性关掉的开关,迟早会有人为了跑得快一点打开它,然后在半年后的一次崩溃里
发现日志对不上。真要更快,该换一种存储形态,而不是把这一种的保证削掉。
## 决策五:阻塞 I/O 丢进线程
存储接缝的六个方法都是协程,而文件写入与 `fsync` 是阻塞调用。`fsync` 在忙盘上可以到几十
毫秒甚至更久,直接在事件循环里做会把同一个循环上所有并发运行一起卡住。所以每次写入走
`asyncio.to_thread`
**两个下游今天都是直接在事件循环里写文件的**(都没有 `to_thread` / `run_in_executor` /
`aiofiles`),其中一个的循环上还挂着一个心跳协程——写文件一慢,心跳跟着晚。它们现在没被这件事
咬到,是因为写得少:一个是整次跑完写一个文件,另一个根本不落盘。本库是每步四次写,量级不同。
**取消能穿过去。** `to_thread` 那一下被取消时,协程立刻抛出取消,而那个线程会把手上这次写
做完——写完的东西留在文件里,没写完的是尾行,按决策三丢掉。两种结果都不会让日志进入说不通
的状态。
## 决策六:运行开始记录用独占创建兜住跨进程撞车
`write_run_started``O_CREAT | O_EXCL` 打开文件,文件已存在就直接失败。
`run` 在开工前会先 `read_log` 判断这个标识有没有日志,但那是**先读后写**,两个进程同时读到
空、同时开始写的窗口它挡不住。独占创建把这个窗口关掉,代价是一个标志位。
两个进程同时跑同一个运行标识的后果很具体:两条交错的记录序会让恢复读到同一步的两条意图,
`_recovery` 的判定那是「日志被并发写过」,于是这次运行从此续不了——而两边的模型调用都
已经花过钱了。
**这一档有真实先例。** 某个下游靠文件名的唯一性避免撞车、不加任何锁,而它的注释记了一次
踩坑:文件名少了一维,同一次 run 的两个阶段写进了同一个文件、后者静默覆盖前者。独占创建把
这类「静默覆盖」变成一次显式失败——**发生了就报错,比发生了没人知道好**。
## 怎么验那两条没有机器兜底的承诺
契约套件验不了原子性的「一起不可见」那一半和前缀持久性,它点名要在这一层补。做法是**给这份
实现一个只在测试里用的故障注入点**:一个可替换的「把这些字节写进去」的内部函数,测试把它换成
「写一半就抛异常」。
**故障点是内部的,不进公共 API。** 契约套件那条 `xfail` 说「给端口加一个『故意在这里失败』
的钩子能验,但那个钩子会变成公共 API 的一部分」——那说的是给**接缝**加钩子。给一个具体实现
的内部留一个可替换点不一样:它不出现在任何接缝签名上,换一个存储实现就没有它。
**前缀持久性仍然验不了**,理由和契约套件那条一样:「已经持久」是掉电之后才看得出来的性质。
这份实现靠形态满足它(同一文件的追加写),只能靠评审看,不靠测试。这一条继续登记为已知缺口。
## 留给后续的
**这套契约测试怎么交给下游跑。** 套件现在住在 `tests/contract/`,随仓库走,不进发布包。下游
要拿它验自己的存储实现,得能 import 到它。做成一个 pytest 插件、做成一个可安装的子包、还是让
下游把仓库作为测试依赖装进去——三条路各有代价,而现在还没有一个下游真的试过跑它。等 ⑥ 迁移
验收撞上这件事再定,不提前挑一条。
**关系数据库那一种形态本文不做。** 另一个下游要写库,但它的表结构、事务边界、连接管理都在它
自己那边,库替它写一个通用实现只会写出一个谁都不合用的。存储接缝的意义就是让它自己实现,而
契约套件是它的准入标准。