存不进数据库的那个 null byte
本文译自 2026-09-24 的英文原文。
(原文写于 2026-09-24;之后 2026-10-06 的迁移 0059 给快照头加了第三个外键 approved_baselines.snapshot_id,指向维护者确认过的基线快照,删除方式是 restrict,不级联。文中「两个外键都是级联删除」因此已改为:三个外键中,工具条目和最新快照指针这两个级联删除。)
2026-09-21 03:48 UTC,探测器为我们跟踪的一台 server 写入了一个工具快照的头行,得到 201 Created;接着发送这个快照的条目,得到 400。同一秒的数据库日志说明了原因:
ERROR 22P05 unsupported Unicode escape sequence
DETAIL \u0000 cannot be converted to text.
CONTEXT JSON data, line 1: …{"allOf":[{"type":"string","pattern":"^[^\u0000…
这台 server 的一个工具声明的输入 schema 里,pattern 是一个以 ^[^\u0000 开头的正则表达式。这是合法的正则,也是合法的 JSON 字符串。可这个字符 PostgreSQL 不会存进 text 或 jsonb 列;而 PostgREST 在还在解析请求体时就拒绝了这个请求,任何 SQL 都还没运行。
它对页面造成了什么
头行已经提交,所以每一轮都会留下一个零条目的快照。没有完整的快照,这台 server 就一直没有最新快照指针,那些 run 没有签名 envelope,它也从未出现在目录里。从 2026-09-17 到 2026-09-21,它每天被探测两次,累积了九个孤立的头行,每轮一个。
此前关于这台 server 的两个猜测,工具名重复和 CPU 时间,都错了。是上面那行日志让事情水落石出,这也很好地说明了为什么要先读日志,再提理论。
为什么这不是 server 的问题
这个工具没有任何格式错误。在字符类里写 \u0000,是表达「除 NUL 以外的任何字符」的普通写法。限制在我们这边:我们的存储放不下这个字符,而探测器照样把它发了过去。
我们用 pg_input_is_valid 对生产库实测了数据库究竟拒绝什么,得到一个精确的集合:
| 输入 | jsonb 是否接受 |
|---|---|
"\u0000" |
否 |
"\ud800"(落单的高代理码元) |
否 |
"\udc00"(落单的低代理码元) |
否 |
"😀"(一对代理码元) |
是 |
"\u0001" |
是 |
"" |
是 |
所以「存不下」恰好意味着 U+0000 或一个不成对的 UTF-16 代理码元,别无其他。JavaScript 字符串可以合法地包含落单的代理码元,而 JSON.stringify 会原样把它写出来,它就是这样被发出去的。
我们拒绝的两种修法
显而易见的修法是在写入前把这个字符去掉。我们没有这样做。报告页展示的是 server 所发布内容的哈希和指纹,读者有权用 server 自己的 tools/list 把它们重新算一遍。如果我们悄悄改写了文本,存下的文本和哈希描述的就不再是同一样东西。
第二种修法我们起了草,后来又撤回了:在 schema 列里写一个标记对象,类似 {"$unstorable": …}。schema 列是第三方文本。server 自己就可以发布这样一个标记,任何依据它来判断的读者,都会把 server 的说法当成我们的说法来转述。
我们实际做了什么
现在每个条目行都带一个列 unstorable_fields,列出它的三个文本列(tool_name、description_raw、input_schema_raw)中哪些无法存储。无法存储的那一列置为 NULL。两条数据库约束让这个声明保持如实:数组只能列出这三个列名,被列出的列必须真的是 NULL。公开的读取角色在这张表上只有列级 SELECT,完全没有写权限,所以这个声明只可能来自我们的探测器。
探测器里的操作顺序是关键。页面上的每一个哈希,name_hash、schema_hash、工具集指纹和快照的内容摘要,都是按观测到的行计算的。只有在那之后,探测器才为插入另外复制一份,把无法存储的列置空。代码里有一段注释说明这个顺序为什么不能动。
如果无法存储的是工具名本身,这一行会得到一个由名字的哈希生成的占位名:unstorable: 后接十六位十六进制数字,unstorable_fields 也会如实标明。server 没法靠给工具起同样的名字来伪造这种状态:它的 unstorable_fields 会是空的。
报告页先读这个声明。描述无法存储的地方,页面显示:
未保存:探测到的原文含本数据库无法存储的字符(U+0000 或不成对的代理码元)。
schema 无法存储的地方,schema 那一行显示 (输入结构未保存:原文含本数据库无法存储的字符)。名字无法存储的地方,页面会解释那个占位名,指纹一节还会多加一句,免得有人以为哈希是按占位名算的:
本页的哈希与指纹按探测到的原文计算,不是按该占位计算。
只有页面上真的出现占位名时,才会显示最后这一句。
同一次改动里的两件小事
孤立的头行本不该存在。现在条目用一次插入写入;如果这次插入因任何原因失败,探测器会删掉它刚写的头行,再把原始错误重新抛出。指向头行的两个外键都是级联删除,所以不存在需要清理的半写状态。
检测本身把 schema 当作结构来遍历,键也包括在内,因为在 jsonb 里键也是文本。它用显式栈而不是递归,也没有深度上限:一个两百层深的干净 schema 必须原样存进去。叶子判断是按 UTF-16 码元逐个写出来的,而不是一个正则。正则 /[\u0000\ud800-\udfff]/ 看起来对,其实是错的:它会匹配一个合法代理对的两半,于是描述里的每个 emoji 都会被宣布为无法存储。正确的写法是 /\u0000|\p{Cs}/u,测试把这两个事实都钉住了。
结果
添加这一列的迁移先在生产库的事务里干跑并回滚,然后于 2026-09-21 13:58 UTC 应用。探测器和网站随后部署。到这台 server 的下一个计划轮次,15:48 UTC,快照完整写入:11 个工具,其中两个的 input_schema_raw 被声明为无法存储并置为 NULL,描述都保留了,有签名 envelope,也有了目录行。九个孤立头行随后被删除,同样先干跑。此后数据库日志里再没有出现过 22P05。
整个数据库里,有两个条目行带着非空的 unstorable_fields。在大约 1,300 台被跟踪的 server 中,恰好一台需要它。这大致符合我们的预期,而这一列仍然值得加:否则就会有一台 server 永远进不了目录,原因却谁也看不到。
数字从哪里来
2026-09-21 修复前后对生产库的只读查询:孤立头行数(tool_count > 0 却没有条目的 tool_snapshots 行,修复前九个,修复后零个)、恢复后的快照(11 个条目,其中两个带非空的 unstorable_fields)、探测器部署以来 PostgreSQL 日志里 22P05 的次数(零),以及上面的 pg_input_is_valid 表。迁移自己的干跑用全部 46,395 个已有条目行验证了这两条约束,其中没有一行需要这个新列。