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

11 KiB
Raw Blame History

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

决策二:一行一条记录,行首加一个类型标签

{"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,关文件之前不 fsyncrename 之后也不 fsync 父目录, 所以它只保证不出现半截文件,不保证掉电后内容还在。严这一档的直接代价是每一步多两次 fsync ——一次几万步的批跑要多花几秒到几十秒,相对于同一批里几万次模型调用可以忽略。

不提供「关掉 fsync」的开关。 关掉之后耐久屏障就不成立了,而恢复的全部正确性建立在它 上面;一个能把正确性关掉的开关,迟早会有人为了跑得快一点打开它,然后在半年后的一次崩溃里 发现日志对不上。真要更快,该换一种存储形态,而不是把这一种的保证削掉。

决策五:阻塞 I/O 丢进线程

存储接缝的六个方法都是协程,而文件写入与 fsync 是阻塞调用。fsync 在忙盘上可以到几十 毫秒甚至更久,直接在事件循环里做会把同一个循环上所有并发运行一起卡住。所以每次写入走 asyncio.to_thread

两个下游今天都是直接在事件循环里写文件的(都没有 to_thread / run_in_executor / aiofiles),其中一个的循环上还挂着一个心跳协程——写文件一慢,心跳跟着晚。它们现在没被这件事 咬到,是因为写得少:一个是整次跑完写一个文件,另一个根本不落盘。本库是每步四次写,量级不同。

取消能穿过去。 to_thread 那一下被取消时,协程立刻抛出取消,而那个线程会把手上这次写 做完——写完的东西留在文件里,没写完的是尾行,按决策三丢掉。两种结果都不会让日志进入说不通 的状态。

决策六:运行开始记录用独占创建兜住跨进程撞车

write_run_startedO_CREAT | O_EXCL 打开文件,文件已存在就直接失败。

run 在开工前会先 read_log 判断这个标识有没有日志,但那是先读后写,两个进程同时读到 空、同时开始写的窗口它挡不住。独占创建把这个窗口关掉,代价是一个标志位。

两个进程同时跑同一个运行标识的后果很具体:两条交错的记录序会让恢复读到同一步的两条意图, 按 _recovery 的判定那是「日志被并发写过」,于是这次运行从此续不了——而两边的模型调用都 已经花过钱了。

这一档有真实先例。 某个下游靠文件名的唯一性避免撞车、不加任何锁,而它的注释记了一次 踩坑:文件名少了一维,同一次 run 的两个阶段写进了同一个文件、后者静默覆盖前者。独占创建把 这类「静默覆盖」变成一次显式失败——发生了就报错,比发生了没人知道好

怎么验那两条没有机器兜底的承诺

契约套件验不了原子性的「一起不可见」那一半和前缀持久性,它点名要在这一层补。做法是给这份 实现一个只在测试里用的故障注入点:一个可替换的「把这些字节写进去」的内部函数,测试把它换成 「写一半就抛异常」。

故障点是内部的,不进公共 API。 契约套件那条 xfail 说「给端口加一个『故意在这里失败』 的钩子能验,但那个钩子会变成公共 API 的一部分」——那说的是给接缝加钩子。给一个具体实现 的内部留一个可替换点不一样:它不出现在任何接缝签名上,换一个存储实现就没有它。

前缀持久性仍然验不了,理由和契约套件那条一样:「已经持久」是掉电之后才看得出来的性质。 这份实现靠形态满足它(同一文件的追加写),只能靠评审看,不靠测试。这一条继续登记为已知缺口。

留给后续的

这套契约测试怎么交给下游跑。 套件现在住在 tests/contract/,随仓库走,不进发布包。下游 要拿它验自己的存储实现,得能 import 到它。做成一个 pytest 插件、做成一个可安装的子包、还是让 下游把仓库作为测试依赖装进去——三条路各有代价,而现在还没有一个下游真的试过跑它。等 ⑥ 迁移 验收撞上这件事再定,不提前挑一条。

关系数据库那一种形态本文不做。 另一个下游要写库,但它的表结构、事务边界、连接管理都在它 自己那边,库替它写一个通用实现只会写出一个谁都不合用的。存储接缝的意义就是让它自己实现,而 契约套件是它的准入标准。