有一类失败很容易让人误判。上游页面已经显示“完成”,监测也拿到了完成回执,按直觉,下一步就是调用 finish,把这次任务收尾。可这一次,finish 稳定地撞上了 FileNotFoundError:它要找的草稿文件根本还不存在。
当时最容易做的事,是给这个异常加一个重试。文件暂时没出现,那就等一会儿再试;如果还失败,再把重试次数加大。这个办法看起来实用,却没有回答最关键的问题:上游说的“完成”,到底完成了什么?如果页面完成只代表结果已经可见,而文件还要等关闭结果页、确认保存之后才落盘,那么重试只是把一个没有定义好的顺序,包装成了一个看似稳妥的等待。
这件事让我重新意识到,异步流水线最危险的地方往往不是中间步骤,而是阶段切换。每一步都可能单独成功,组合起来却不成立。结果页完成不等于产物已经落盘,任何依赖文件的收尾动作都必须等待可观察的保存条件。
看到完成时,事情其实还没有结束
这次场景并不复杂:一条自动化视频流水线先等待结果页完成,再进入收尾。监测逻辑捕捉到结果页已经完成,于是把这个事件当成了上游阶段的终点。接下来的 finish 需要读取草稿文件,结果却直接报文件不存在。
如果只看页面,判断并不荒谬。页面确实完成了展示,用户也能看到结果。但页面状态和持久化状态不是同一个状态。页面可以先把“结果可看”画出来,随后才在关闭页面、确认保存等动作之后,把草稿写入外部存储。中间那段时间很短,却足以让下游抢跑。
如果不处理这个边界,后果不只是偶发报错。流水线可能把一个尚未保存的任务当成失败,或者为了绕开异常重复触发 finish,造成重复收尾、错误回执和难以解释的残留文件。更糟的是,偶尔成功会让问题更难定位:环境快一点时能跑通,环境慢一点时才暴露顺序错误。
所以我没有先改异常提示,也没有先延长一个固定睡眠时间。我先把“完成”拆成两个问题:界面是否完成展示,产物是否满足下游读取条件。前者是视觉事件,后者是文件系统上的可观察事实。只有后者成立,收尾才有资格开始。
FileNotFoundError 暴露的是顺序问题
最容易误判的地方,是把 FileNotFoundError 当成了路径错误。路径当然值得检查,但这次异常具有一个很重要的特征:直接 finish 会稳定触发,而关闭结果页并确认保存后,文件才有机会出现。这种稳定性说明,问题不是某个字符偶尔写错,而是调用顺序固定地早了一步。
错误捷径是“看到上游返回完成就立刻调用下游”。它把一个事件当成了整个状态机的终点。另一个错误捷径是只给 FileNotFoundError 包一层重试。重试可以缓解暂时性延迟,却不能替代保存动作;如果从未执行关闭和确认保存,重试再多次,也只是在重复询问一个尚未被创造出来的文件。
我选择先修正保存等待,是因为它处理的是根因:把页面结果变成可消费的持久化产物。具体顺序应该是先完成关闭结果页和确认保存,再进入一个有上限的轮询,等待目标文件出现;只有文件出现,才允许 finish 读取它。轮询必须有截止时间,因为“等文件出现”不是无限授权。超过时限仍未出现,就应该让这次任务以明确失败结束,并保留足够的状态说明。
这里的关键不是多等几秒,而是把等待对象从“时间”换成“条件”。固定等待只能猜测保存速度;条件等待则直接观察下游真正依赖的事实。可靠的异步等待不是睡眠一段时间,而是在有限时间内等待一个能被验证的状态成立。
把收尾写成有顺序的状态机
我后来把这条流水线按状态重新看了一遍。结果页完成只是“结果已展示”,接下来还要经过“结果页已关闭”“保存已确认”“草稿文件已出现”,最后才是“允许收尾”。每个状态都应该有进入条件和可观察证据,不能只依赖上一阶段口头上的“完成”。
这样的拆分看起来比直接调用多了几步,但它减少了隐含假设。比如,关闭结果页并不自动证明文件已经写完,因此关闭之后仍需要轮询;文件出现也不自动证明所有复检输入正确,因此收尾前还要核对实际产物和实际源输入。状态机的价值,就是把这些容易被一句“完成”掩盖的间隔显式留下。
在第 4 轮排查里,还发现了另一个边界:异常回执缺少 done 字段时,失败有可能被误判为成功。表面上看,这是回执格式问题;实际上,它和前面的时序问题属于同一类风险——系统把“不确定”当成了“完成”。我因此采用 fail-closed 的判断:成功必须明确带有 done,异常回执没有 done 时不能默认成功,而应进入失败分支。
这条规则很保守,但我判断它是必要的。自动化系统里,错误地把失败当成功,比明确报错更危险。明确失败会阻止后续动作,迫使我们处理问题;误判成功则会让流水线继续运行,直到更晚的步骤以另一种形式爆炸。在异步边界上,缺失成功证据就应该按失败或未完成处理,而不是靠默认值替系统做乐观解释。
复检必须跟着真实输入走
收尾阶段还有一个容易被主异常遮住的问题:封面复检使用了错误的默认首帧。复检逻辑拿到的不是这次实际使用的源输入,而是一个默认首帧,于是 band_diff 从正常值被放大到54。若只看数字,可能会以为封面生成出了严重偏差;回到输入链路才发现,算法比较的对象一开始就错了。
这说明验收不只是“最后再跑一个检查”。验收本身也有输入契约:它必须接收本次任务实际使用的源素材、实际生成的结果和对应的比较条件。如果复检偷偷回退到默认输入,哪怕算法本身没有问题,结果也不能代表这次任务。
我把这个问题和保存顺序放在一起修,是因为两者都在提醒同一件事:状态和数据必须同时传递。文件何时出现,是时序契约;复检比较哪个首帧,是数据契约。缺了任意一个,最终的“失败”都可能只是系统自己制造出来的。复检如果使用了错误输入,后续再调阈值、改算法,往往是在修一个不存在的问题。
如果不处理这个输入边界,流水线可能先因为文件不存在失败,修完等待以后,又因为错误首帧把正常结果判成异常。这样会产生一种错觉:问题层层增加,系统越来越不稳定。实际上,前一个错误只是让后一个错误一直没有机会被看见。
用真实回执把顺序锁住
修复之后,我没有只看一次成功运行。顺序问题必须用真实数据来验证,因为模拟一个理想回执,很容易遗漏字段缺失、保存延迟和输入传递错误。检查重点有3个:结果页完成后是否先完成关闭和确认保存;文件出现之前是否不会进入 finish;异常回执缺少 done 时是否明确失败。
同时,封面复检要确认传入的是实际使用的源输入,并检查 band_diff 回到合理范围。这里的“合理”不能靠一条漂亮的日志判断,而要和这次真实产物、真实回执对应起来。只有输入、输出和判断结果能够互相解释,验收才不是形式上的最后一步。
最后用合同测试把这条顺序固定下来,相关5个测试文件共43个用例通过。这个数字本身不是文章的重点,它更像一张边界清单:保存动作、限时等待、错误回执和复检输入都被分别覆盖。一个流程成熟的标志,不是它能顺利跑通一次,而是它能把关键顺序变成下一次仍然有效的合同。
这件事让我重新意识到,失败信息只是系统露出来的一小块表面。FileNotFoundError 没有告诉我“应该先关闭页面”,band_diff 也没有告诉我“比较对象拿错了”。真正的排查工作,是把异常放回状态转移和数据流里,看它在哪个边界上失去了证据。
下次我会怎么做
下次遇到异步产物在收尾阶段失败,我会先问4个问题:上游的完成究竟代表展示完成还是持久化完成?下游依赖的文件或数据有什么可观察的出现条件?成功回执是否有明确字段,缺失时是否会 fail-closed?复检是否拿到了本次实际使用的源输入?
然后我会把每个阶段写成一条有顺序的状态链:先完成保存动作,再限时轮询文件;先确认成功证据,再允许下游继续;先传递实际输入,再解释复检结果。遇到异常时,先复现时序,再对照真实回执,最后用合同测试锁住顺序。
我更愿意把这看成一种工作习惯,而不是某个具体脚本的技巧。异步系统的难点,不在于每一步会不会做,而在于我们是否承认“做完”有不同层次。页面亮了、文件落了、回执完整、验收通过,这些都应该各自拥有证据。把证据留在状态机里,下一次就不必靠临场猜测判断流水线究竟走到了哪里。