新增 trap 20:复用预备份时重新造一份证据,会让已登记的写集合锚点被整份 抹掉(现场 backup_bytes:0 + writeset_* 全丢)。写明症状、根因、负例优先的 判据与修法,含预备份版本行「一条只属于首次登记的作业」这一归属语义。 AGENTS.md 索引表那一行受写入保护、需主人批准,本次未改(见交接单待批准项)。
35 KiB
已知陷阱(全文)
这份文件是
AGENTS.md第 4 节那张索引的全文。拆成两份是有原因的:AGENTS.md作为"工作区指令"会被自动加载,而指令有字节预算 —— 超出的部分会被 静默截断,没有任何报错。索引留在指令文件里(一行一条,一眼扫到),全文放这里。纪律:只追加、不重排、不复用旧号 —— 编号会被代码注释、测试、文档引用(形如"见 trap-3")。 新增一条要改两处:这里加全文 +
AGENTS.md的索引表加一行。
trap-1:.gitignore 对已入库的文件无效
症状:规则明明写了 __pycache__/,git ls-files 里却仍有 8 个
scripts/__pycache__/*.pyc。
根因:.gitignore 只影响"未跟踪"的文件。文件一旦进了索引,规则对它就完全不起作用,
而且不会有任何提示。
判据:git ls-files | grep -E '__pycache__/|\.pyc$' 输出非空即是这条。
修法:规则 + git rm -r --cached <path>(保留磁盘文件)。
规则齐全不等于没被跟踪 —— 断言要查两件事:规则在、且 git ls-files 里没有。
反例:只加 .gitignore 规则就以为修好了。下次 git add -A 时它照样进来。
trap-2:git rm --cached 之后跑 git add -A,文件会被搬回索引
症状:执行了 git rm --cached node_modules,git status 也显示删除了,
提交后 git ls-files 里那个文件仍在,净效果为零。
根因:--cached 只从索引移除、磁盘文件留着。如果此刻忽略规则还没生效
(本仓当时只写了 /web/node_modules/,管不到根级 node_modules),
紧接着的 git add -A 会把这个"存在且未被忽略"的文件重新加回索引。
判据:git rm --cached X 后、提交前,先跑 git check-ignore -v X ——
必须命中一条规则,否则 add -A 会把它捡回来。
修法:先让忽略规则生效,再撤出索引,并且此后只 git add <具体文件>,
不要用 -A。
反例:把两步写成一条 git rm --cached X && git add -A && git commit。
trap-3:用子串判断代替整行判断
症状:脚本判断"规则已存在"时写 if "/node_modules/" not in 全文,
而文件里已有 /web/node_modules/ —— 它包含这个子串,于是误判为"已存在",
跳过了追加。结果规则根本没加上,而脚本打印"规则已存在"。
根因:.gitignore 的 /web/node_modules/ 与 /node_modules/ 语义完全不同
(前者只管 web/ 下),但作为字符串是包含关系。
判据:任何"这条规则在不在"的判断,都要对行集合做精确比较
({l.strip() for l in lines}),不要对全文做 in。
反例:if "xxx" not in text 这类判断在多条相似规则并存时必然出错。
trap-4:Gitea 合并接口成功时返回空 body
症状:脚本调用合并接口,HTTP 200,但解析 JSON 抛
json.decoder.JSONDecodeError: Expecting value: line 1 column 1,
看起来像"合并失败",于是重试 —— 而 PR 其实已经合并了。
根因:Gitea 的 POST /pulls/{n}/merge 成功时 body 为空(不是 {"merged":true})。
把"HTTP 200 + 空 body"当成失败是客户端的问题,不是服务端的问题。
判据:合并后 不要信 POST 的返回,改 GET /pulls/{n},
要求 state=closed + merged=true + merge_commit_sha 非空。
反例:在合并接口上做重试。永不重试 —— 会得到"第二次合并失败"的假故障。
trap-5:浅克隆里 git describe --tags 会静默退化(≠ 不能用 tag)
症状:想让版本号来自 git describe --tags,在 CI 上失败或给出误导结果。
根因:工作流里是 git fetch -q --depth 50 origin "$BR",浅克隆不带 tag。
git describe 要么报 No names found,要么退回到一个和预期无关的短 sha。
⚠️ 这是"不要在构建时靠 git describe 取版本号",不是"本仓不用 tag" —— 正式版本
就是用注释 tag vX.Y.Z 标记的(由人批准后创建)。
判据:任何依赖 tag 历史的自动步骤,必须在 CI 里显式
git fetch --tags / --unshallow;否则就是这条。
修法(本仓采用):
- 版本号的唯一来源是仓库里的
CHANGELOG.md(python3 scripts/version.py),文件一定在,不依赖 tag; 构建身份(含开发标识)用scripts/version.py --identity,make version打印它。 - tag 一致性由人可核验的方式检查:检出步骤专门落一份远端 tag 清单
(
.ci-remote-tags)并git fetch --tags取对象,scripts/check_changelog.py --tags再核 「tag 版本 == 它指到那个提交里的版本记录」、且必须是注释 tag; 没有 tag 不判红,但没核对远端就不许说"校验成功"(CI 用--require-remote强制)。
反例:VERSION=$(git describe --tags --always) —— 本地看着正常,CI 上静默退化。
反例二:检查器在拿不到远端 tag 清单时照打 ✅ 校验通过 —— 那是"假绿",现在会明确写「未核对远端」。
trap-6:act_runner 不会自动检出代码
症状:工作流第一步就报找不到文件 / 空工作区。
根因:本仓的 act_runner 不会替作业做 git clone,工作区起始为空。
判据:工作流里若没有 git init + git remote add + git fetch + git checkout,
就是这条。
修法:照 .gitea/workflows/build.yml 的「检出代码」步写(带 x-access-token 的
remote,令牌来自 secrets.GITHUB_TOKEN)。
反例:以为 runner 会像 GitHub Actions 那样自动 checkout,直接写业务步骤。
trap-7:grep -q 在 pipefail 下会被 SIGPIPE 谎报失败
症状:echo "$OUT" | grep -q 'PASS' 明明匹配上了,整条命令却返回非 0
(141),在 set -o pipefail 的作业里表现为"检查失败"。
根因:grep -q 一命中就退出并关闭管道,上游 echo 随即收到 SIGPIPE,
整条管道返回 141。
判据:set -o pipefail + ... | grep -q ... 同时出现即是这条。
修法:用 case "$OUT" in *PASS*) ... ;; esac,或让 grep 直接读文件;
非用管道不可时用 set +e; set +o pipefail + ${PIPESTATUS[0]}。
反例:if echo "$x" | grep -q y; then —— 时红时绿,且方向完全错。
trap-8:gofmt -l 列出文件时退出码仍是 0(只看退出码=假绿)
症状:门禁里写着 gofmt -l .(或 step gofmt -l . || fail=1),格式乱掉的文件明明被列出来了,
这一步却通过。整条门禁全绿,而仓库里躺着一堆没格式化的文件。
根因:gofmt -l 的正常行为就是"列出不合规的文件并返回 0" ——
它的退出码表达的是"我跑完了",不是"都合规"。用退出码当判据,等于没判。
判据:任何"输出即问题"的工具(gofmt -l、prettier --list-different、git status --porcelain)
都必须判输出是否为空,不能判退出码。
修法:[ -z "$(gofmt -l <文件...>)" ],或者像本仓 scripts/check_format.py 那样解析输出;
并且给它一条故意弄坏的夹具证明它真的会红(否则"永远返回 0 的校验器"会一路绿灯)。
反例:step gofmt -l . || fail=1 —— 生成器里就是这么写的,一直没人发现。
trap-9:tar -C dir . 产生 . 根成员 ⇒ 预览 404
症状:打包与上传都成功、通知也发了,点开预览链接却是 404 或报错。
根因:tar -czf x.tgz -C dir . 会把 .(以及 ./index.html)写进成员名。
严格的解包器要么拒绝整包,要么解出一层多余的 .,于是 index.html 不在预期的位置上。
判据:tar -tzf <包> 的第一行若是 . 或 ./…,即是这条。
修法:先 cd 进产物目录,再用通配展开成员名(( cd "$WEB" && { shopt -s dotglob; tar -czf "$TAR" *; } )),
并在发之前断言包里真的有 index.html(tar -tzf "$TAR" | grep -qx 'index.html')。
反例:只看"打包成功 / 上传 HTTP 200"就当预览发好了 —— 那两步都不会因为包结构不对而失败。
trap-10:依赖清单被静默改写
症状:一次 CI 通过之后,go.mod / go.sum / package-lock.json 与提交里的不一样了;
本地跑一遍还多了/少了几个依赖,没人改过它们。
根因:两个"顺手"的默认行为 —— GOFLAGS=-mod=mod(构建时自动补全/更新 go.mod)、
以及缺锁文件时回落到 npm install(按 package.json 重算整棵依赖树并重写锁文件)。
两者都让"依赖清单"变成运行时产物,而它本该是人 review 过的契约。
判据:夹具 = 门禁开始前后对这几个文件取 sha256;任何一步改写了它们 ⇒ 红。
修法:GOFLAGS=-mod=readonly;缺锁直接失败(不接受无约束安装);
npm ci(按锁装、锁与 package.json 不一致就报错)。见门禁的 deps-install 与 manifests-locked 两项。
反例:if [ -f package-lock.json ]; then npm ci; else npm install; fi —— 看起来"更健壮",
实际是给漂移开了后门。
trap-11:汇总把「未运行 / 被跳过」当「通过」
症状:某个必需检查压根没跑(失败即停的后果、或运行时被 if: 跳过),
汇总却报"全绿";事后看报告,那一项根本不在报告里,谁都没发现。
根因:汇总逻辑只遍历"跑过的项"。没跑的项不出现 ⇒ 集合变小 ⇒ 全绿。
另一种写法是 skipped 与 success 一起放行。
判据:给汇总喂一个"必需项缺失 / 必需项 skipped"的夹具,它必须非绿。
修法:清单里声明必需项集合,汇总按清单(不是按结果数组)逐项核对;
缺项记 not_run 并判红;aggregate() 只认 success。
不适用要在清单里声明(本仓的 required_in),不能在运行时悄悄 skip。
反例:status = "failure" if any(x == "failure" for x in results) else "success"。
trap-12:事件 SHA ≠ 检出 SHA
症状:CI 里跑的是"分支当前 HEAD",而这次运行的事件指向的是另一个提交。 表现是"我刚推的那一步好像没跑",或者更糟 —— 拿新代码的结论去解释旧代码的产物。
根因:工作流写 git fetch --depth 50 origin "$BR" && git checkout -B "$BR" FETCH_HEAD,
取的是当时的分支 HEAD。只要在事件产生之后又有人推了一次(或重跑旧运行),两者就分叉。
判据:git rev-parse HEAD 必须等于 ${GITHUB_SHA}(完整 SHA,不是短 sha)。
不一致 ⇒ 红。
修法:检出后立刻核对;不一致时改按精确 SHA 取(git fetch --depth 50 origin "$EVENT_SHA"),
再取不到就失败。门禁的 identity 项会再核对一次(两道,故意的)。
反例:报告里只写"分支 main 的构建成功" —— 这个结论没有对应的提交。
trap-13:密钥扫描规则过宽 ⇒ 永远的假红
症状:接入密钥扫描后立刻红,命中全是正常代码:postgres://{u.quote(user)}:{u.quote(password)}@host/db
(模板表达式)、postgres://governance:***@postgres/governance(脱敏占位)、
X-CI-Token: ${BRIDGE_TOKEN}。
根因:规则用 [^@\s]{6,} 这种"很宽"的字符类去抓口令段,于是把变量插值、脱敏星号、
模板表达式全部当成密钥。假红一旦出现,人就会把整个检查注释掉 —— 那比没有检查更糟。
判据:扫描器必须自带双向夹具:合成的真密钥要命中,良性对照要零误报, 而良性对照里要钉住踩过的误报形状(防回归)。
修法:只收"干净字符"的口令段([A-Za-z0-9+/=_\-]{6,}),并对命中值做占位符判定
(${...}、<...>、***、changeme…)。见 scripts/check_secrets.py 的 RULES 与 --self-test。
反例:为了让门禁变绿去放宽/注释规则 —— 那是降低规则换绿灯,规范 §4.2 明确禁止。
trap-14|"这个语言我没写检查" = 假报成功(通用模板的 Python 门禁空跑)
症状:通用模板套到一个 Python 项目上,deps-install / format / static / build / tests 五项全绿,
但一个 Python 检查都没跑 —— 函数体只打印一句话就 return SUCCESS。
根因:模板最初只为 Go+Node 写实现,Python 分支留了"占位返回成功"。占位返回成功不是"还没做", 是假报成功:它会让人以为这个项目被检查过。审查是靠"模拟一个 Python 项目,看那五项是不是真的执行了" 才发现的 —— 只看 YAML/看代码是看不出来的。
判据:每一门识别到的语言都必须真的执行;工具缺失、依赖没按锁装、输出解析不了 ⇒ 失败。
修法:uv sync --frozen / uv run ruff format --check / compileall / uv run pytest;
need() 找不到工具就抛错(转成 FAILURE)。前端同理:测试进程退出码非零必须红
(报告写 pass 不算数),框架按 test-scope.json 声明,认不出来就失败。
trap-15|预览放在"报告核对"之前 = 发出去就收不回来
症状:预览步骤只读 overall_status + commit_sha 就发布;完整报告核对在后面的通知步骤。
报告缺项时,预览已经发出去了,几分钟后才报错 —— 那时 URL 可能已经被人打开/转群。
根因:把"发布"当成"通知"的一环。预览是不可撤回的对外动作,不能排在质量结论之前。
判据:发布前必须调用同一份核对逻辑(ci_report.verify()),有任何问题 ⇒ 一个 publish 请求都不发。
修法:.gitea/scripts/ci_preview.py 内部先核对再发布;scripts/ci_preview_negative_test.py
用假 bridge 数请求证明坏报告 0 次、好报告 1 次。
trap-16|用"观察到的 context"代替"要求哪些检查"
症状:合并前没指定必需 context 时,脚本拿"体检时在该仓看到过哪些状态"当必需列表。
根因:combined state 只报"报过状态的都成功",一个无关的检查成功就能让它变成 success。
于是"没跑过必需检查"的 PR 会被合进去。
判据:要求哪些检查只能来自明确策略(--require-context 或 .gitea/required-contexts.txt);
策略缺失或为空 ⇒ 拒绝合并(空不等于"没有要求")。
修法:见 scripts/adopt-existing.py 的 resolve_required_contexts() 与 --self-test-chain
(假 Gitea + 真实 merge_pr(),断言 merge 请求发没发)。同时核对 base 分支/PR 状态/head 仓库。
trap-17|remote 协议被"顺手"拼成 http
症状:注释说"保留服务端给的协议",代码却是 git remote add origin "http://${HOSTPORT}/..." ——
服务端给的是 https 时会静默降级成 http(凭据与产物走明文)。
根因:把 ${SRV#*://} 取出来的 host:port 再拼一个固定的 scheme。取 host 的动作本身就把协议丢了。
判据:remote 直接用 GITHUB_SERVER_URL 原样拼,并断言最终 scheme 与服务端一致;
未知 scheme 直接拒绝;URL 里不许带凭据。
修法:.gitea/scripts/ci_checkout.sh(--print-remote 可本地验证协议保留);
YAML 里只调用它,不再内联这套拼接。
trap-18|范围校验回退成"最近 N 条"或"接受截断的列表"
症状:范围算不出来时退化成 git log -n 5(和历史无关),或直接采信事件负载里那串 commits。
根因:事件负载里的 commits 可能是截断的(实测:声明 6 条、列表只给 1 条),
浅克隆里 before 可能根本不存在 —— 两种都会让"本次改动"少核几条,而且全绿。
判据:以准确 Git 范围为准(push 用 before..after,新分支用 after --not --remotes);
事件声明条数与 Git 范围不一致 / 空范围 / 浅克隆取不到 before / 列表提交不在检出里 ⇒ 全部拒绝;
手工运行比不出范围时只核 HEAD,但必须写明。
修法:.gitea/scripts/ci_gate.py 的 resolve_scope()(--self-test 11 个分支)+
检出脚本顺带把事件 before 取下来。
trap-19|第一步就调用仓内脚本 = 整条流水线起不来
症状:workflow 第一步写 bash .gitea/scripts/ci_checkout.sh。本地"已经有代码的目录"里跑得好好的,
真到 Runner 上返回 127(bash: .gitea/scripts/ci_checkout.sh: 没有那个文件或目录)——
因为这个 Runner 不自动检出,工作区初始是空的(trap-6),此时仓内脚本还不存在。
根因:把"检出"写成了仓内脚本,而"检出"恰恰是让仓内文件出现的那一步 —— 循环依赖。 本地验证只在有代码的目录里做,就永远看不到这个形状。
判据:第一步必须是最小 bootstrap:只依赖 runner 给的环境变量(GITHUB_SERVER_URL /
GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_TOKEN),自己 git init + remote add + 取事件完整 SHA
- checkout + 断言 HEAD==事件 SHA。检出完成、脚本就位之后,才调用仓内脚本做后续校验与补取。
修法:build.yml 第一步内联 bootstrap → 然后 bash .gitea/scripts/ci_checkout.sh --post-checkout。
scripts/ci_chain_fixture.py 会把那段 run: 原样抽出来在空目录里执行(YAML 一改坏就红)。
trap-20|fetch origin <SHA> 取任意提交(服务端默认不允许)
症状:分支 HEAD 已经前进时,bootstrap 想"按精确 SHA 取",执行 git fetch origin $GITHUB_SHA
→ 服务器不允许请求未公开的对象(upload-pack 默认不开放按任意 SHA 取)。
根因:把 --depth 取分支的写法套到按 SHA 取上。这个能力在默认配置下不存在。
判据:事件 SHA 必须能从已取到的分支历史里找到;不够深就 --deepen 再找;
仍找不到才是失败(提示提高深度或等重跑)。失败要明确,不许"取不到就换个分支糊过去"。
修法:见 bootstrap 里的 git cat-file -e "${GITHUB_SHA}^{commit}" + fetch --deepen 200 分支。
trap-21|after --not --remotes 在检出后把范围排成空集
症状:新分支推送(before 为全 0)用 after --not --remotes 算范围。CI 检出时
refs/remotes/origin/<本分支> 往往已经等于 after —— 那个引用把自身排除了,范围恒为 0 条,
闸门直接失败(或更糟:被当成"没有要检查的提交"放过去)。
根因:拿"刚 fetch 下来的同名远端分支"当排除集。它是被检出的那个分支,不是基线。
判据:新分支的基准只能是远端默认分支(事件里的 repository.default_branch),
范围 = refs/remotes/origin/<default>..<after>;取不到基准 ⇒ 明确失败,不许退化成整部历史或空集。
修法:ci_gate.resolve_scope();ci_checkout.sh --post-checkout 负责把默认分支引用取下来。
--self-test 里有专门的回归用例(origin/<本分支> == after 时必须仍算出那几条提交)。
trap-22|把事件里的 total_commits 当成"声明条数"
症状:新分支推送的真实事件里 Gitea 给的是 total_commits=0、commits 列表被截断(实测 5 条),
而 Git 范围算出 12 条。严判"声明 ≠ Git 范围"⇒ 门禁在第一次真实 CI 就红了,
红的原因与代码质量无关(本地夹具用的是"声明 6 条/列表 1 条"的自造形状,覆盖不到这一种)。
根因:total_commits 在 Gitea 的语义不是"这次推送的提交条数",新分支推送时就是 0
(等于"没声明")。把它当权威声明去比对,等于用一个不存在的约束判红。
判据:Git 范围是唯一权威。total_commits > 0 时才拿来交叉核对(不一致 ⇒ 拒绝,
覆盖"声明 6 条 / 列表 1 条 / Git 2 条"那一类);<=0 或缺失 ⇒ 视为未声明,
以 Git 范围为准,并把"负载条数不可信、列表被截断"写进来源说明里。
修法:ci_gate.resolve_scope()(--self-test 里有专门的 total_commits=0 分支)。
教训:负例要照真实事件负载的形状造,不能只造自己想的形状。
trap-14:包一层装饰器就丢掉"可选接口"⇒ 写集合台账被静默跳过
症状:真同步作业成功、阶段全绿,但预备份版本证据里没有 writeset_digest,
归档根下也没有 writesets/。崩溃接管时 loadWriteSetForJob 立刻读不到台账,
作业只能失败并把目标留在维护里。
根因:libraryrelease.Run 用类型断言决定要不要在写第一笔之前算写集合
(target.(WriteSetPlanner))。worker 把目标包成 contentTrackedTarget 以标记
"内容可能半写",但这个包装只转发了 Current/Apply/Verify/Restore —— 断言
因此恒为假,整段台账逻辑被静默跳过(没有日志、没有错误)。
判据:任何 *_test.go 里断言 libraryrelease.Target(包装体).(WriteSetPlanner)
必须为真;或直接查真作业的预备份 evidence->>'writeset_digest' 是否非空。
修法:包装层显式转发该方法;内层不支持时返回硬错误,不要退回"没有台账也照写"。
更一般的规则:只要上游靠可选接口(XxxPlanner/XxxObserver)决定行为,
每个包装层都要么转发它,要么让断言失败可见。
反例:只给内层目标写测试(它当然实现),从不测"包装后的目标还实现吗"。
trap-15:Stat/Delete/Copy 与 Open/Put 吃不同的定位符形状
症状:对象明明在桶里,Put 与 Open 都成功,Stat 却报
invalid release object read 或 404。
根因:真实资源路径是 storage://<backend>/<provider>://<bucket>/<key>(嵌套
scheme)。validObjectKey 认这种形状并把它拆成裸 key,ValidObjectLocator 只认单层;
Open/Put 用前者,Stat/Delete/Copy 用后者、而且直接把整串当对象键。
判据:集成用例必须喂真实定位符(含 storage:// 前缀与内层桶),负例覆盖
"键不存在 / provider 不符 / 路径穿越 / 复制源不存在"。
修法:所有按路径操作的入口共用同一对函数(validObjectKey +
providerPathFromLocator),测试不要只喂裸 key —— 裸 key 会让这条不一致完美隐身。
反例:if _, _, err := store.Stat(...); err == nil { t.Log("...") } —— 读不到也
算通过,等于把断言关掉了。
trap-16:"资源不在预备份范围"不等于"属于别的知识库"
症状:同步 A 成功 → 手工恢复 B(resources 表不删行、对象也不删)→ 再同步同一个 A, 预检在写第一笔之前整次拒绝:
资源 ID 冲突:<id> 存在于目标租户但不在预备份范围内(属于其它知识库),拒绝覆盖
根因:resources 是多知识库共享表,只有 tenant_id;预检把"同租户 + 不在预备份
范围"当成"别人的行"。但恢复 B 的预备份是按当时的绑定/正文引用取范围的,A 写进去、
恢复后变成无绑定的资源不在它里面 —— 这些行恰恰是本目标自己上一次成功同步的结果。
"目标里没人引用"不是归属证据,历史孤儿、其它知识库暂未绑定的资源同样
满足它;按它放行就是允许覆盖别人的行。
判据:真实现场(同步 A → 恢复 B → 再同步 A)必须能完成,且复用资源的 PG xmin
不变、universeDump 里历史孤儿/其它知识库/外租户行逐字节不变;把同一行的内容改一个
字段后必须继续拒绝;没有持久来源时必须继续拒绝。
修法:只走"可审计复用"这一条放行路径(internal/releaseadapter/v071/library_reuse.go):
持久来源(这个目标成功同步过且登记生效版本的快照,或它自己的预备份)+ 整行内容
(除易变时间戳)一致 + 对象字节与归档摘要一致,三者同时成立才允许复用;复用行在写集合
里是 existing+reused,写入阶段跳过(不重写行、不重传对象),恢复也不清理它。
证据单独落盘在 reuse/<digest>.json,摘要在写集合行里 —— 不冒充历史台账。
反例:为了"让同步能过"把"不在预备份范围"直接放行,或者恢复时按"没被引用就删"清理。
trap-17:分页窗口当索引 —— "当前生效版本"不能按最近 N 条找
症状(比 trap-14 更隐蔽:它不报错,只是判错):
- 接管时把一个"其实已经生效"的作业判成
fresh,于是把已经上线的目标重写一遍; - 基线入口为一个已经有权威版本的目标放行"建基线"(会覆盖别人的版本);
- 页面上"生产当前版本"显示成"没有生效版本记录",同步闸口据此要求先建基线;
- 只有把窗口 N 从 50 调到 100/200 才"暂时好一点",再长一点又复发。
根因:LibraryVersions(scope, N) 是分页展示用的窗口(真实上限 1..100),
它按 created_at DESC 截断。可"当前生效版本"是每 scope 唯一一行、可能很久以前
登记的事实;"本快照是否生效过"更是身份查询。用窗口当索引,历史一长就把存在
读成不存在——而且读成"没有"不会报错,只会让错误的选择看起来合理。
判据:把生效版本登记在 48 小时前、再灌 250 条更新的版本记录,然后:
LibraryVersions(scope, 100)里看不到它(用例先断言这一点,否则夹具失效);LibraryCurrentVersion(kind='production_effective' AND superseded_at IS NULL) 仍精确命中,接管判定是pending_final而不是fresh;- 换个更新的快照生效后,旧快照
LibraryVersionOfSnapshot仍取回并带superseded_at,接管判定superseded(拒绝用旧快照覆盖); - 台账被写坏(同一身份两行、未顶替却不是当前生效、已顶替却是当前生效)⇒
拒绝挑选 / 判
unknown,不许挑一条"看起来对"的继续写。
修法:身份/当前状态一律走精确查询(LibraryCurrentVersion、
LibraryVersionOfSnapshot、LibraryVersionByRef),窗口只用于列表展示;
同一含义的判定(worker 接管、基线、同步闸口的创建判据、归档保留的保护标记)
共用同一份实现,别各写一份"取最近几条再筛"。
反例:把窗口上限调大、或在内存里 for 一遍再 break——那是换了个数字,
不是换了判据。
18. 快照相等 ≠ 本作业完成:接管判据必须绑作业身份
症状(2026-10-29T19:16Z 真实现场):作业只写完了写入链路的第一个阶段
(database,resources/index/wiki/verify 还是 pending),worker 被杀、人工拉起后
又被判成"succeeded / finalize succeeded",阶段证据写"已登记生效版本(仅收尾,未重写生产)",
并且把生效版本的归属改写成这个作业自己。
为什么它看起来是合理的:分类只看一个事实——"当前生效版本的目标快照是不是我的目标快照"。
是 ⇒ pending_final(只差收尾,只做校验+登记)。而这个环境里同一个快照会被多个作业
发布(同一份冻结候选重复同步),于是"生产上有这个快照"被当成"我这个作业写完了"。
根因:library_versions 的主键由 (kind, scope, 快照) 决定
(LibraryVersionID),登记是 ON CONFLICT (id) DO UPDATE,会改写 source_job_id。
所以"快照相等"里没有作业身份这一维;只看快照就永远分不清"我发的"和"别人发的"。
判据(负例优先):
- 内存:当前生效快照 == 目标快照,但
source_job_id是别的作业 ⇒ 必须是foreign_effective(不是pending_final);把归属改成本作业才允许pending_final; - 真实 PG:先
RecordLibraryVersion一个source_job_id=别的作业的生效行,再loadLibraryVersionFacts+classifyTakeover,断言foreign_effective; 再用本作业登记同一快照(upsert 改写归属),断言pending_final; - 撤掉身份判定后这两条必须红(实测:
classify = "pending_final")。
修法:effectiveOwnedBy(version, jobID, snapshotID) 一份实现,接管与基线共用;
foreign_effective 走"拒绝接管"的失败出口(failLibraryTakeover):不写目标、
不释放维护、终态失败并指明预备份基线——目标可能半写,释放等于谎称已恢复。
演练侧的同一条:不同快照才证明完整续作。目标快照 == 中断前的生效版本时,
脚本在杀之前就拒绝(--allow-same-snapshot 只用于诊断,不算验收证据);
成功判据里必须包含"database/resources/index/wiki/verify 五个阶段全部 succeeded"。
19. 接管证据:汇总日志与阶段文案都不是"这个作业被接管了"
症状(2026-10-29T19:56Z 真实现场):不同快照的 after:database 演练里,作业最终
succeeded、database/resources/index/wiki/verify 全 succeeded、生效版本归属本作业、
维护两把锁都释放了,目标内容也确实恢复到 1/2/9;但演练脚本 exit 5,理由是
"worker 日志里没有本作业的 resume 记录:接管来源不明"。
为什么它看起来是合理的:worker 日志里明明有
library job takeover sweep: 1 resumed, 0 failed without backup。但这行是汇总:
既不说哪个作业,也不说它是否真被本进程认领(另一个 worker 抢先认领、attach 失败
都只影响计数)。脚本要的老文案 library job <id> resumed after worker restart
只在"新建队列行"那条路径打印;真实走的是"队列行已存在"(崩溃前队列行还在)那条,
那条只把计数 +1。
根因:判据绑在一条只在部分路径打印的文案上,而且没有作业身份校验。
另一半是阶段语义:真实接管是"复用崩溃前那份预备份、重跑整条写入链路",
release.go 会把 prebackup 记成 succeeded("预备份 "),不是 skipped。
老判据要求 prebackup:skipped,于是真实有效的预备份也会被判红——但不能因此
取消备份校验(见下)。
判据(负例优先):
- 认领行(
library job <id> takeover claimed at=… kind=… scope=… target=… backup=… queue=… path=…) 只在claim+attach都成功之后写;claim=false或attach失败时一行都不许有 (撤掉"先认领后写日志"的顺序 ⇒ 用例红); - 决策行(
… takeover decision=fresh|pending_final|…)在动手之前写;after:database之后判成pending_final就是上一轮的假完成(负例:把决策改成pending_final⇒ 判据红); - 判据脚本只认带本作业 id 且时间戳晚于杀点的行:只有汇总行、别的作业的认领行、
杀之前的旧日志,三种都必须红(
scripts/takeover_log_contract.py --self-test逐条证); prebackup允许skipped或succeeded,但必须同时证明"复用同一份、没被重做覆盖": 预备份快照 id 不变 + 清单文件字节/mtime 不变 + 版本行created_at仍是首次登记时刻 (RecordLibraryVersion对production_prebackup取LEAST,生效版本仍写当前时刻)- 证据里有
backup_reused=true。取消这些校验等于允许"崩溃后重做备份",那会把半写状态 当成可回滚基线。
- 证据里有
修法:cmd/worker/library_takeover_log.go 一份实现(认领行/决策行),两条认领路径
共用;scripts/takeover_log_contract.py 是唯一判据(含自检,进 make verify);
演练脚本 verify_takeover_semantics 改成上面那组断言。
20. 复用预备份时"重新造一份证据" ⇒ 已持久化的写集合锚点被整份抹掉
症状(2026-10-30T00:58Z 真实演练,job librestore-internal-1793321916382871758-retry):
SIGKILL 137 后按策略重启,接管认领与决策行都对,database/resources/index/wiki/verify/finalize
全部 succeeded,预备份快照 id、manifest 的 hash/mtime、首次 created_at 都没变,
backup_reused:true 也在;但 verify_effective 失败 —— 生效链上的
writeset_digest 从 fddd98cf70… 变成空。直接读生产库:
production_prebackup : {job, backup_bytes:0, backup_reused:true, backup_snapshot, index_mapping_sha256}
production_effective : {…, backup_bytes:0}
writeset_* 一个都不剩。同一作业第二次中断后再接管,loadWriteSetForJob 已经读不回
记录(预备份 … 没有登记写集合摘要)—— 恢复链在"再次故障"这一档彻底断掉。
根因:BackupPrepared 每次都新造一份证据 map(只放 backup_snapshot/backup_bytes/
job/index_mapping_sha256/backup_reused),而 RecordLibraryVersion 的 upsert 是
evidence = EXCLUDED.evidence(整份覆盖)。接管路径上:
ledgerBackupStore只带WriteSet(没有字节数)⇒ 新证据里backup_bytes写 0;WriteSetPlanned只在首轮预检时调用(backup.WriteSet == nil才规划), 接管复用不重算 ⇒ 被抹掉的writeset_*没有任何一条路径会补回来。
判据(负例优先):
- 真实 PG:
BackupPrepared→WriteSetPlanned→loadWriteSetForJob→ 接管Run(复用)→ 再loadWriteSetForJob必须仍取回同一摘要;连做两轮中断+ 第三次完整恢复后,证据里writeset_digest/writeset_rows/backup_bytes(真实值)/job/ backup_snapshot/backup_reused逐项还在,created_at等于首次登记时刻; - 把合并改回"新造一份 map"⇒ 该用例必须红(实测:
第 1 次接管后写集合丢失); - 复用却没有任何已登记证据、字节数与已登记不一致、证据
job/backup_snapshot与本次不符 ⇒ 一律失败(不许"看起来对就合",那会把别的作业的写集合带进来); - 同一作业第二次预检出不同
writeset_digest⇒ 失败(台账不再描述"实际写了什么"); - 复用拿不到真实字节 ⇒ 失败,不许把 0 写进证据(
backup_bytes:0与"空备份"不可区分)。
修法:prebackupEvidence(cmd/worker/library_writeset.go)一份实现,先核
job/backup_snapshot 身份再合并旧证据;接管路径用 prebackupBytesOf 从首次
登记的证据取回真实字节交给 ledgerBackupStore(Bytes<=0 直接拒绝);
生效版本证据统一由 effectiveEvidence 构造。预备份版本行的归属语义:
一条预备份版本行只属于首次登记它的那个作业,别的作业撞同一个快照 id 由
LibraryVersionByRef 报"属于作业 X"——那不是"没有记录",不许覆盖。