k8e 是一个内嵌 etcd 的 Kubernetes 发行版,497 星,维护者是 Deshi (Tommy) Xiao。9 月 20 日他开了 #612,要给内嵌的 etcd 做一套端到端的健壮性测试,分四期:先测崩溃后能不能安全恢复,再测多节点故障、备份能否还原、长时间运行是否稳定。
10:28(北京时间,下同)他给票打上 ai-ready,交给了 Orbi。下面的时间和数字来自这张票的标签历史、PR 和评审记录,只有 runner 崩溃的次数取自 runner 日志。
第一版被打回了
11:37,Orbi 开出 PR #613,只做第一期:10 个新文件,2483 行。内容包括一个记录每次操作的历史记录器、一个判断恢复结果对不对的判定器(oracle),以及几组测试:写到一半 SIGKILL 子进程、故意损坏 WAL、写满 quota。
11:46,维护者把 SonarCloud 和 DeepSource 扫出来的问题贴了上来,Orbi 11:57 开始推修复。
13:21,维护者账号上的评审给了 REQUEST_CHANGES。这时 PR 已经涨到 2695 行。评审提了三条:
R1:判定器放过了一个不可能出现的结果。 两个 CAS 都以 revision 3 为前提,A 确认成功,B 结果未知。恢复后如果读到的是 B 写的值,那就说明出错了:两个比较里总有一个会失败,A 已经成功,B 不可能也成功。判定器却判了通过,因为它把结果未知的 CAS 当成了不带条件的普通写入。
对应的操作历史:
k is at rev 3 A: CAS rev3 -> a acked B: CAS rev3 -> b unknown recovered: k = b oracle: OK (should fail)R2:记账的就是被杀的那个进程。 记录器和 etcd 跑在同一个进程里,测试一 SIGKILL,记录器也跟着死。一次写入可能已经确认成功,确认还没落进日志,进程就没了。这条写入于是被记成「结果未知」,真丢了测试也发现不了。票里写的要求正好相反:记录器必须放在被测节点外面。
R3:空值和不存在分不清。 写入空值时不算哈希,判定器就把「写了一个空值」当成「这个键不存在」。空值键丢了,测试照样通过。
评审还说,PR 正文里的 Fixes #612 会让这张四期的票在第一期合并时就被关掉。
三条都跟代码风格无关。每一条都会让测试在数据没恢复对的时候报「恢复正确」。测试本来就是用来抓这种问题的,它自己报假平安,比没有测试还麻烦。
Orbi 自己也出了错
同一个 PR 上,Orbi 的引擎公开报了三次自己的失败,都留在 PR 评论里:
- 12:28,想给票加一个仓库里没有的标签
ai-awaiting-merge,失败。12:37 引擎放弃,把票标成ai-blocked,意思是交给人来决定下一步。维护者 14:16 摘掉这个标签,14:41 重新打上ai-ready,才接着往下跑。 - 14:54,Orbi 自己的独立评审没输出结论行,结果解析不出来。
- 15:19,续跑时的校验要求 PR 正文里必须有
Fixes #612。可这一行正是维护者评审要删的,而且他说得对。
第三个问题 Orbi 一直没自己解决。更糟的是,这次校验失败没有只卡住这一张票,而是让服务他这个仓库的整个 runner 进程崩溃。runner 日志里,15:19 到 22:10 一共崩了 84 次,他整个队列都停着。公开复盘 orbi#1219 是崩到一半时提的,里面记的是 77 次。19:59,维护者在票下留言「继续修复吧」,之后也没有动静。「PR 必须写 Fixes」这条规则,放在一次做完的票上没问题,放在分期的票上就错了。引擎死守规则,这回是人工评审对。
怎么修的
15:05,一个 commit(f8e9dd9c)把三条都改了:
- 结果未知的 CAS,如果它预期的 revision 比该键最后一次确认的 revision 还旧,就不再算作可能的结果。
- 子进程里只跑 etcd,客户端和记录器挪到父进程,子进程被杀后记录器还在。
- put 和 CAS 的 payload 一律算哈希,空字符串也算。
R1 和 R3 各补了正反两个方向的测试。PR 正文改成了「Part of #612 — this PR lands the phase-1 layer only」。
22:12,第二轮评审给了 APPROVED。它逐条核对了修复,提了两个合并前的条件:一是自己再跑一遍 race 测试;二是把 Orbi 第一个 commit message 里残留的 Fixes #612 去掉。第二条被标成可选,因为评审认为只有 PR 正文里的关键字才会关票。还没覆盖到的地方,设计文档里写了,评审也逐项列了。38 秒后,22:13,维护者自己点了合并。从记录里看不出这两个条件在合并前做过。合并时 CI 一共 801 个测试,新增 49 个,没有失败。
票还是被关了
评审对 commit message 的判断错了。合并进默认分支的 commit,只要 message 里有关闭关键字,GitHub 就会关掉对应的票,不光看 PR 正文。合并后一秒,#612 被关成了 completed,关它的就是 Orbi 的第一个 commit。四期只做完了一期。第一轮评审担心的正是这个,最后它从唯一没改的地方漏了过去。
这是 Orbi 在这张票上犯的第四个错,后果也最重。我们引擎的规则只要求 PR 正文写 Fixes #N,交付 agent 又额外把它写进了第一个 commit message。两轮评审都点过这个关键字,第一轮说的是 PR 正文,第二轮说的是 commit,票还是关了。写这篇文章的时候,#612 仍然是关闭状态。
第二张票:从开 PR 到合并 3 小时 12 分
第二天是 #614,做 rqlite 兼容,对应 M0 这一关。维护者 15:21 打上标签。PR #615 改了 11 个文件,加了 3294 行,16:49 开出,17:33 起进入等批准的状态。维护者账号上先出现了一条 LoopX 的评审意见,20:00:52 他点了批准,29 秒后 Orbi 合并。
回头看
Orbi 这次没有一次做对,维护者也出手了好几回:解除阻塞、把票放回队列,Orbi 停住时催它继续,最后绕开 Orbi 一条不适合这张票的规则,自己合并。结果一张四期的票,还是在第一期后就被关了。
不过评审说「不行」的时候理由是具体的,修复也落在同一个 PR 上。在别人的代码库里接一张难票,从开 PR 到合并用了十个半小时。所有失败,包括 Orbi 自己的,都留在公开记录里,可以拿这篇文章去对。
事后 Orbi 改了两处(orbi#1219)。一是 PR 正文不合规时,只让这一张票失败,不再拖垮整个 runner。二是工作流规范里写明:分期的工作不要用一张票承载,维护者先把每一期拆成子票,每张子票的 PR 只关它自己。
如果你的仓库里也排着一串写清楚了的 issue,可以让 Orbi Cloud 在你的仓库里跑同样的流程。