Files
PolyLoop/research-wiki/design/0011-jsonl-run-store.md
T
iomgaa 183113f624 docs: 0011 与 0012 转已接受,README 阶段清单勾到 ⑤
④ 与 ⑤ 都勾上但各自注明了欠账:e2e 那一层还是空的(它要打真实模型网关);事件出口没有调用
点(Event 还没有字段);stores 只有逐行追加那一种形态。勾上是因为骨架与十个模块确实都落地
了,注明欠账是因为清单是进度的权威处,含糊会让人以为这两件事已经做完。
2026-08-10 04:24:01 -04:00

235 lines
17 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 · **状态** 已接受(2026-08-10 项目负责人确认)
**落实** `0003-public-api-shape.md` 决策四与 `0005-storage-atomicity-and-record-fields.md`
决策二、五。那两份定了存储接缝有哪六个方法、写入粒度是什么、哪两次写是耐久屏障、以及前缀
持久性这条要求;本文定**第一个真实实现**怎么满足它们。
**触及** `../../src/polyloop/stores/`,以及 `../../tests/contract/` 那套套件——存储那部分在本文
落地之前**全部跳过**,因为没有实现可接。(那套套件里另有两条标着 `xfail`,那是两条**已知没有
机器兜底**的承诺,和「还没有实现」是两回事,见文末。)
## 读本文需要的几个名字
**一步之内写四次**`0003` 决策四):模型调用意图 → 模型调用结果 → 动作意图 → **逐步结果**
最后那一条是**一条记录**,里面同时装着动作结果与步记录——`0005` 决策二要求它们一次原子落地,
所以它们本来就是同一个记录类的两个字段,不是两次写。加上一次运行开头的「运行开始」与结尾的
「运行结束」,一次两步的运行一共写十次。
**记录类一共五个**:运行开始、意图(模型调用与动作共用一个类,靠一个字段区分)、模型调用
结果、逐步结果、运行结束。
**耐久屏障**:一次必须确认已经落盘才能往下走的写。四次写里前三次的第一次和第三次是屏障
——意图必须在副作用之前就持久,否则「做过没有」这个问题事后没有答案。
**前缀持久性**`0005` 决策五):第 k 次写被确认持久时,第 1 到 k-1 次也已经持久。有了它,
不是屏障的那几次写也不会掉在屏障后面。
**恢复判定**`polyloop._recovery`,库内部的一个纯逻辑模块):拿一份读回来的日志,按「意图
有没有 / 结果有没有」判每一次执行处在哪一态,据此决定从哪儿接着跑。
**⑥ 迁移验收**是 `../../README.md` 那份阶段清单的最后一项:真的把两个下游迁过来,以两边测试
全绿为准。
## 这份实现为什么值得一份 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` 的编码说明里。
**不加下划线前缀之类的记号,虽然那样从形状上就撞不了。** 这份日志的一个明确用途是让人和模型
直接翻文件读(`0005` 决策一),而 `_record` 这种键在那种场景里读着像内部字段、像不该看的东西。
一个普通单词加一条「不许再叫这个名字」的约束,换来的是每一行第一眼就读得懂。
**取值就是记录类名的蛇形写法**`run_started` / `intent` / `model_call_result` /
`step_completed` / `run_finished`),不另起一套短名。短名省的那几个字节抵不上「查一个名字要
先查一张对照表」的成本。
## 决策三:判据是「这一行有没有被换行终结」,不是「它能不能解析」
文件末尾那段**没有换行的**字节丢掉;**每一条被换行终结的行都必须解得开**,解不开就是损坏,
直接报错。空行跳过——它不携带记录,也不是撕裂的证据。
**为什么尾行可以丢。** 一次写入是先写整行、再由调用方等它返回。进程被杀在 `write` 中途,
文件末尾留下的那段字节对应的那次写**从来没有被确认过**,按契约它就是「没发生」。丢掉它正是
「要么都可见、要么都不可见」的落地方式。
**为什么判据不能是「能不能解析」。** 这一条是对抗审查逼出来的,失败场景很具体:`os.write`
允许短写,而短写完全可能正好写完整个 JSON 对象、只差最后那个换行。那段字节解得开,于是一条
**从没被确认过的动作意图**被当成有效记录读回来;恢复据此判成「状态未知」,声明可重放的话就
把那个动作再执行一次——**而它一定没执行过**,因为调用方是在写意图返回之后才去执行的。
换行是「这一行写完了」的唯一凭据,所以判据只能是它。
**为什么终结过的坏行不能丢。** 追加写只在末尾产生撕裂;一条完整终结的行读不了,说明别的东西
(写坏、外部改动、两个进程交错写)动过这个文件。这时候跳过它接着读会拼出一份少了几条记录但
看起来完整的日志,而恢复会照它做判断。`0002` 决策二那条「读到说不通的状态就失败,不修复也不
带着它继续」在这里同样适用。**按这条规则,「末尾连着两条坏行」也是损坏**——第一条终结过的
坏行就已经报错了。
**「碰到坏行就放弃这个文件」和某个下游今天的做法一致**:它的轨迹检查器把整个文件的读取包在
一个 `except (json.JSONDecodeError, UnicodeDecodeError)` 里,撞到第一条坏行就把整个文件降级成
一条违规,而不是跳过坏行接着读;它还有一条专门造「轨迹文件被截断成半行」的测试。这条不是我们
新发明的谨慎。
## 文件的字面约定
外部读取方要知道的就这几条:**UTF-8 编码,每行以 `\n` 结尾,非 ASCII 原样输出不转义**
`ensure_ascii=False`,那份日志给人读、也给模型读,转义成 `\uXXXX` 谁都难受)。JSON 自己会把
换行、制表符这类控制字符转义掉,所以**一条记录里的换行不会把它拆成两行**。
目标目录不存在时**创建**,不报错——运行标识是调用方给的,目录是它配的,第一次跑时它还不存在
是正常情形。
## 决策四:一步之内四次写有两次 `fsync`,加上运行开始与运行结束
| 写什么 | 每步几次 | `fsync` | 为什么 |
|---|---|---|---|
| 运行开始 | —(整次一回) | 是 | 见下面那段,它的理由比别的都硬 |
| 模型调用意图 | 1 | 是 | 耐久屏障:必须落盘才能发出调用 |
| 模型调用结果 | 1 | 否 | 后面紧跟的不是副作用 |
| 动作意图 | 1 | 是 | 耐久屏障:必须落盘才能执行动作 |
| 逐步结果 | 1 | 否 | 同上。它是**一条**记录,动作结果与步记录是它的两个字段 |
| 运行结束 | —(整次一回) | 是 | 丢了的话这次运行看起来还能续,而它已经跑完了 |
**不做 `fsync` 的那两次靠前缀持久性兜。** 同一个文件的追加写,后一次 `fsync` 会把它之前的
全部内容一起刷下去,所以「模型调用结果还没落盘,动作意图(屏障)落了盘」这个状态在这份实现
上不可能出现——`0005` 决策五要求的正是这个,而这份实现是天然满足的那一类。
**运行开始那次还要把父目录也刷一遍。** `fsync(fd)` 刷的是文件内容,刷不到「这个目录里多了
一个文件」这条目录项。掉电之后内容可能在、而**文件根本不存在**——那时读日志走「文件不存在」
返回空日志,驱动入口据此判成一次全新的运行,于是一次已经开始过、可能已经花过钱的运行静默
没了留痕。这比「读不出配置」严重一档,是这一次 `fsync` 真正的理由。只在新建文件时做:往已有
文件追加不改目录项。
**每次写都是「打开、追加、按需 `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` 那一下被取消时,协程立刻抛出取消,
**但那个线程不会被打断**——它会把手上这次写做完,那一行是完整的。所以取消这条路上根本不产生
尾行;产生尾行的是另一件事(进程被杀),那时线程连同整个进程一起没了,写到一半的那段按决策三
丢掉。两种情形都不会让日志进入说不通的状态。
**为什么是 `to_thread` 而不是 `run_in_executor` 或者 `aiofiles`。** 前者是同一件事的老写法,
还要自己管一个执行器;后者是一个第三方包,而本库的核心没有运行时依赖,为一个存储实现引一个
包不值。代价是 `to_thread` 用的是默认线程池(上限随 CPU 数走),并发运行很多时写入会排队——
但每次写只占用线程几毫秒,而每一步中间隔着一次几百毫秒的模型调用,排不到那儿去。真排到了,
那说明该换一种存储形态了。
## 决策六:运行开始记录用独占创建兜住跨进程撞车
`write_run_started``O_CREAT | O_EXCL` 打开文件,文件已存在就直接失败。
`run` 在开工前会先 `read_log` 判断这个标识有没有日志,但那是**先读后写**,两个进程同时读到
空、同时开始写的窗口它挡不住。独占创建把这个窗口关掉,代价是一个标志位。
**它只挡住「两个进程都在新开一次运行」这一种。** 续跑不写运行开始记录,所以两个进程同时续跑
同一个标识挡不住——那时两条交错的记录序会让下一次恢复判成日志损坏。这一档现在没有跨进程的
机器保证,登记为已知缺口;进程内那一半由存储自己按运行标识加锁挡住(一条记录可能由不止一次
`os.write` 写完,而 `O_APPEND` 只保证每一次 `os.write` 的追加位置原子,保证不了一条逻辑行整体
原子)。真要跨进程挡,得引一把文件锁或者让调用方自己排他,那是它的编排层该管的事。
两个进程同时跑同一个运行标识的后果很具体:两条交错的记录序会让恢复读到同一步的两条意图,
`_recovery` 的判定那是「日志被并发写过」,于是这次运行从此续不了——而两边的模型调用都
已经花过钱了。
**这一档有真实先例。** 某个下游靠文件名的唯一性避免撞车、不加任何锁,而它的注释记了一次
踩坑:文件名少了一维,同一次 run 的两个阶段写进了同一个文件、后者静默覆盖前者。独占创建把
这类「静默覆盖」变成一次显式失败——**发生了就报错,比发生了没人知道好**。
## 怎么验那两条没有机器兜底的承诺
契约套件验不了原子性的「一起不可见」那一半和前缀持久性,它点名要在这一层补。做法是**给这份
实现一个只在测试里用的故障注入点**:一个可替换的「把这些字节写进去」的内部函数,测试把它换成
「写一半就抛异常」。
**故障点是内部的,不进公共 API。** 契约套件那条 `xfail` 说「给端口加一个『故意在这里失败』
的钩子能验,但那个钩子会变成公共 API 的一部分」——那说的是给**接缝**加钩子。给一个具体实现
的内部留一个可替换点不一样:它不出现在任何接缝签名上,换一个存储实现就没有它。
**前缀持久性仍然验不了**,理由和契约套件那条一样:「已经持久」是掉电之后才看得出来的性质。
这份实现靠形态满足它(同一文件的追加写),只能靠评审看,不靠测试。这一条继续登记为已知缺口。
## 留给后续的
**这套契约测试怎么交给下游跑。** 套件现在住在 `tests/contract/`,随仓库走,不进发布包。下游
要拿它验自己的存储实现,得能 import 到它。做成一个 pytest 插件、做成一个可安装的子包、还是让
下游把仓库作为测试依赖装进去——三条路各有代价,而现在还没有一个下游真的试过跑它。等 ⑥ 迁移
验收撞上这件事再定,不提前挑一条。
**关系数据库那一种形态本文不做。** 另一个下游要写库,但它的表结构、事务边界、连接管理都在它
自己那边,库替它写一个通用实现只会写出一个谁都不合用的。存储接缝的意义就是让它自己实现,而
契约套件是它的准入标准。