4f3f43d218
0011 最要紧的一条是文档和代码对不上:Codex 那轮把坏行判据从「第一条解不开的行」改成了「有没有 被换行终结」,文档还停在旧规则上。改完顺带答掉冷读问的「末尾连着两条坏行算什么」——按新规则 第一条终结过的坏行就已经报错了。 三处确定性矛盾全部成立:标题写「fsync 在三处,其余三处不做」而正文写「那两次」、表格里 fsync=否 只有两行(改成按「一步之内四次写」重排,并把「处」的单位说清);「五个记录类」里没有「动作 结果」(它是逐步结果那条记录的一个字段,不是第六个记录类,表格行名会误导);「每步四次写」与 表格看着像五次(同一根因)。另外契约套件的状态从「24 条全跳过」改成不给会过期的数字,并把 skip 与 xfail 分开说——它们是「还没有实现」与「没有机器兜底」两回事。 还补了:一节名词解释(前缀持久性、耐久屏障、恢复判定、⑥ 都是首次出现即使用);「取消能穿过去」 那段原本自相矛盾(说线程会把写做完,又说没写完的是尾行);文件的字面约定(UTF-8、\n 结尾、 非 ASCII 不转义、目录不存在时创建)——这些恰恰是外部读取方必须知道的,而文档反复强调那份日志 要能离开这个库读懂;为什么保留键叫 record 而不是加下划线前缀;为什么用 to_thread;独占创建 只挡住一种撞车(两个进程同时续跑挡不住,登记为已知缺口);以及运行开始那次 fsync 真正的理由 是目录项而不是「读不出配置」。 最实的一条留到最后:那五维里的题目很可能带中文或空格,过不了运行标识的字符判据,而同一份 文档又规定不做转义。现在写明编码方式归下游自己选(要单射),并说清库为什么不替它选——库一旦 选了,文件名就不再等于运行标识,而它按标识去目录里找文件的用法就断了。migrations/dissect.md 同步登记。 0012:把 parent_card_id 这个笔误改成 parent_call_id(冷读的人不知道哪个对,只能问「card 是 什么」,正好把它顶出来);「每个源报四样」实际枚举了五样;补一节名词解释(scope、源、恒定采样 参数、推理开关全是首次出现即使用);补上本库这一侧的接缝签名与 ModelCall 的五个字段,并说明 中间那三个为什么一个都不往下传;补上非文本块报错、拼接不加分隔符的代价、1.1.1 那个 bug 到底 是什么、空串调用标识是防御而不是常规路径、以及为什么超时与重试次数不算模型身份。
235 lines
17 KiB
Markdown
235 lines
17 KiB
Markdown
# Design 0011 · 逐行追加的日志存储
|
||
|
||
**日期** 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 插件、做成一个可安装的子包、还是让
|
||
下游把仓库作为测试依赖装进去——三条路各有代价,而现在还没有一个下游真的试过跑它。等 ⑥ 迁移
|
||
验收撞上这件事再定,不提前挑一条。
|
||
|
||
**关系数据库那一种形态本文不做。** 另一个下游要写库,但它的表结构、事务边界、连接管理都在它
|
||
自己那边,库替它写一个通用实现只会写出一个谁都不合用的。存储接缝的意义就是让它自己实现,而
|
||
契约套件是它的准入标准。
|