两条注释,一个函数,没有共识
我们的 Play 发布适配器里有一个函数,负责把一个进来的 release 合并进一条已经有一些 release 的轨道。它有两条规则,而它们在源码里紧挨着。
第一条关于 completed release。一条轨道上不能有两个,新的 completed 取代旧的。它上面的注释解释了为什么这件事明明看着显然却仍然重要:
// ...androidpublisher rejects a
// tracks.update carrying two with 400 releasesTooManyCompletedReleases —
// which classifies as retryable, so every promotion after a product's first
// would burn its whole River budget on a condition that never clears.
第二条关于 draft,几周后一次真实的构建死掉之后加的。它解释的是同一种失败:
// Play answers 400 INVALID_ARGUMENT "Only one draft release is allowed.",
// which classifies PERMANENT — and with auto_submit
// off an unpromoted draft is the normal resting state, so every automatic
// build after the first fails for good rather than retrying into success.
一个说 Play 的 400 归为可重试,另一个说 Play 的 400 归为永久。相隔十六行,在同一个函数里,关于同一家厂商、同一个 HTTP 状态码。
第二条是错的,而且这个错并不微妙。
我们自己的代码怎么对待 400
永久性不是错误本身的属性。在这个系统里它是一个决定,由一个判定函数做出,而那个判定就在旁边:
func (e *HTTPError) IsPermanent() bool {
if e.IsEditUnavailable() {
return false
}
return e.StatusCode >= 400 && e.StatusCode < 500 &&
e.StatusCode != http.StatusBadRequest && // 400,被点名排除
e.StatusCode != http.StatusUnauthorized &&
e.StatusCode != http.StatusForbidden &&
...
}
400 被点名排除。401、403、408、409、429 也是。4xx 里其他的一切都是永久的,那六个不是。同一个包的测试说得很直白:永久性原谅 400,是因为其中好几个会自己好。
而这不是判定里的一个 bug。它上面的注释给出了完整论证,而且论证是好的:
// 400/401/403 are excluded because permanence here is not free: the publish
// handler turns it into FailUpload, and a terminally failed candidate needs
// a whole new build. Google returns 403 for rateLimitExceeded/quotaExceeded
// as readily as for a genuine permission problem, and the token endpoint
// answers 400 invalid_grant for host clock skew and for a key that has not
// finished propagating — all of which clear on their own in minutes.
论证是这样的:把一个错误标成永久不是一个标签,而是一个「扔掉这个构建」的决定。你不能对每一个 400 都做这个决定,因为有些 400 是暂时的,而把暂时误判成致命,代价是一次重建和一个版本号。所以 400 默认得到原谅,判错的代价花在重试预算上,而不是花在这个产物上。
那是一个被考虑过的取舍,为一个正当的理由做的,写在决定它的那段代码里。
那个错误实际上做了什么
所以那个 draft release 并没有被判为永久。它被判为可重试,这在一个特定的方向上更糟,在任何方向上都不好。
更糟:客户端状态违规不会自己清掉。重试一个永远会被拒绝的请求不是韧性,是一个把预算烧光然后照样失败的循环。completed 那条注释说的正是这件事,而且它是对的:一个产品第二次之后的每一次 promote 都会把整个重试预算烧在一个永远不会清掉的条件上。draft 那半是同一个形状。不是永久性地坏掉了,而是永远地坏掉了。
在任何方向上都不好:对 400 的原谅在这里也帮不上忙。它帮的是 400 暂时的那种情况。这一个不是暂时的。原谅在这里只是把一次瞬间的失败变成一次缓慢的失败。
真正的修法
修法没有碰那个判定函数,而这是对的。把 400 改成永久会更糟:那会破坏宽容所存在是为了服务的那些情况——时钟偏移和 key 传播——只为了修一个本来就不该被产出出来的状态。
取而代之,合并现在像 completed 规则一样强制执行 draft 规则:
- 这个函数读轨道,把进来的 release 合并进去,再把整条轨道写回。它从不新建第二个 draft,也从不删除任何东西;它是读、合并、写,而最终轨道上带着两个 draft,因为合并那一步对这种情况没有任何规则。
- 规则:当进来的 release 是 draft 而某个已有 release 也是 draft 时,新的取代旧的。draft 不服务任何人,所以两个方向上都没有任何东西上线或下线,而留在轨道上的正是那个人真正想 promote 的那个构建。
- 分组的 draft 跟着它的目标一起走。如果 Play 因为设备覆盖把一个 draft 和其他 version code 分到了一组,那些兄弟会被带过去,而不是被留成一个第二 draft,也不是被丢掉——丢掉会静悄悄地缩小 CI 辛辛苦苦建出来的设备覆盖。
- completed 规则对它的兄弟所用的推理,在这里因为同样的理由成立:把目标摘下来并丢掉其余部分,和取代它不是同一件事。
加了两条测试,改了第三条。被改的那条有意思。
断言了非法状态的那条测试
一条既有测试断言了:一个分组的 release 被进来的 draft 拆开之后,剩下的 version code 会作为它们自己的 release 留在轨道上。也就是说,这条测试断言操作之后会有两个 draft。
那正是 Play 用 400 拒绝的状态。这条测试把一个非法状态编码成了预期结果,而它一直是绿的,因为它忠实地测试了代码做的事。测试没有说谎;代码说了谎,而测试同意代码。
这是一个假实现建模了错误的东西时会做的事。那个假实现允许任何一组 release 被写到一条轨道上,而 Play 不允许。于是每一个用了这个假实现的测试,测的都是一个比真实系统更宽松的系统,而那些测试对一个不存在的程序是正确的。
改掉之后的预期不是一个小编辑。它断言结果 release 列表的长度和目标 version code 的内容,这与之前的断言是不同的断言。旧的那条讲的是摘离。新的那条讲的是轨道最终停在 Play 接受的状态上。
比修复活得更久的注释
那个提交里有一个细节属于「会撒谎的注释」这个故事,因为它就是同一个物种。
函数自己的 doc 注释,在最上面,仍然这么写着:
// mergeTrackRelease upserts targetVersionCode, keeping the staged, halted and
// draft releases already on the track — but never a second completed one.
修复之后这句是错的。draft release 不再被保留,最新的那个取代其他的。注释描述的是修复前的行为,修复改了函数体,而 doc 注释被留下来描述那个 bug。
这个文件此后没有被动过。那条注释还在。
注释没有被任何东西验证。它在每次构建里都编译通过,穿过每一次重构,而且只被一个人读:恰好在调试的那个人。
没有哪条测试能抓住一条过期的注释,默认 lint 工具里也没有哪一条会为它失败,评审步骤也不会可靠地做这件事。唯一的防御是把注释写成一个「有人会注意到它是假的」的断言,而唯一有帮助的机制是就近:一条关于某条规则的注释应该挨着那条规则,而不是待在一个写一次就再也没回来看过的头部。
这件事到底是什么
出了两件事,值得分开,因为它们的修法不同。
一个关于世界的事实进了注释,然后从来没被核对过。 Play 的 400 分类不是代码的属性;它是错误处理策略的属性,而那份策略住在另一个函数、另一个文件里,并且是为别的理由写的。那条注释报告了一个结论,却没有指向它所总结的那个东西的指针。一条写着「这个归为永久」的注释,质量只等于读者愿意去核对的程度,而它读起来是板上钉钉的事实。
一条注释把一种情况描述成了全部情况。 draft 那条规则里那个错误的说法不是发明的,它是 completed 那条规则的措辞,被搬到了一个不同的情形上。「draft 兄弟可以合法共存」是从一个「目标是 completed」的语境里搬过来的,而当目标本身就是 draft 时它不成立。从相邻的代码块里复制一条注释,是一条错误的主张如何在一个文件里以自信的口吻出现的方式。
一般形状:注释是一个关于别处那个事实的主张,两者之间没有任何东西连接它,而它拥有源码的全部权威,却没有源码的任何核对。修法不是少写注释。是让主张指向它的 owner:点名那个函数、那个判定、那个文件。一个能跟着指针走的读者可以核对它;一个只能读那句话的读者只能相信它。
小结
在一个函数里,两条注释对同一家厂商的 400 给出了相反的分类,相距十六行。一条对,一条不对;而知道是哪一条的办法,是去读真正做决定的那个判定,它在另一个文件里,是为另一个理由写的。
把 400 排除在永久之外是一个取舍,不是疏忽:标为永久意味着丢弃构建,而有些 400 确实是暂时的。这个取舍是站得住的。把后果说反了的那条注释站不住。
后果本身依然很糟。一个永远清不掉的客户端状态违规,被重试到预算耗尽,是一次缓慢的失败而不是快速的失败。修法是停止产出那个状态,而不是把错误分类得更好。一条从不产生该条件的规则,不依赖分类器是对的。
一条测试可以把一个非法状态编码成它的预期值,并在代码同意它的时间里一直保持绿。这条测试断言轨道最终会带着两个 draft,而这正是 Play 拒绝的东西。它忠实地测试了代码,而代码是错的。
而函数顶部的一条 doc 注释,可以在修复它的那处改动就坐在下面十五行的情况下是错的。没有什么会编译出这个事实,没有什么会测它,也永远不会有任何东西发现。注释是关于别处那些事实的主张,而让一条注释可被核对的,只有它通往事实所在之处的那个指针。