IT加油站

定时任务空跑40次零报错:一个断言揪出静默失败

84浏览 • 14天前 • 软件教程 • MA123973

原文:https://dev.to/samhartley_dev/my-scheduled-agent-ran-40-times-and-did-nothing-heres-the-assertion-that-fixed-it-50g2(作者 @samhartley_dev)

这个问题是我偶然发现的——而这几乎就是发现它的唯一方式。

每晚 02:30,一个定时任务会从某个 API 拉取新文件,做标准化处理,然后丢进一个队列,供我的早间简报使用。它已经连续跑了大约六个星期。每一次运行:退出码 0,日志里没有任何报错。调度器面板上显示连续四十次绿色(成功)。如果你当时问我,我会说这个任务是我手里最可靠的一段自动化代码。

后来我注意到简报变「薄」了。不是空的——是变薄。有的日子还有两条,有的日子一条都没有,而我一直默认是「最近消息少」。我终于起了疑心去翻日志,结果发现我根本没有任何可供起疑的线索:日志里每次运行都只有一句 fetched items, wrote to queue,仅此而已。没有数量,没有 ID,只有一句我自己很久以前写下、此后从未验证过的话。

于是我加了一行代码——输出数量——然后手动重跑了一次。

零。每晚都是零。整整四十次。

HTTP 调用返回了 200,携带的却是一个空数组——因为查询里的一个参数在对方那边被改了名(since 改成了 from_date),而这个 API 对未知参数的处理方式是乐呵呵地直接忽略。我的 for 循环遍历了一个空列表,然后成功跑完——遍历空列表的 for 循环本来就会这样。没有异常抛出,没有重试,也没有任何东西被记成问题,因为在我整个技术栈的每一层看来,确实没有任何问题。

这就是我想写的失效模式,因为其他几种我已经补过了:硬故障我有熔断器,Agent 濒临挂掉有健康监控,任务重复触发有去重方案。但这三个方案盯的都是「有事情发生」。没有一个在盯「该发生的事没发生」。而「没发生」不会抛异常。

错误会告警,沉默不会。

这件事拖了六个星期才被发现,原因是结构性的,不是我懒。异常是有「时刻」的:它抛出、被处理器捕获、告警发出。而一次静默的空操作没有时刻。它只有一种随时间变化的形态——而且前提还是你把该画的东西画成了图。

我的监控画错了东西。我追踪的是运行状态(success/failed)和运行时长。这两项都完美无缺。而我唯一没追踪的,恰恰是唯一重要的数字:产出的效果。运行完成了,效果是零。我的面板之所以一片绿,是因为它度量的是系统对自身的看法。

修复的办法不是加更多日志,而是换一个问题:不是「它跑了吗?」,而是「它改变什么了吗?」

规则一:断言效果,而不是尝试

现在我写的每一个任务都必须声明:一次成功的运行在副作用层面长什么样——写入了多少行、发出了多少条消息、创建了哪些文件、吐出了哪些 ID。然后任务必须先通过这个断言,才有资格报告成功。

def assert_effect(name, produced, expected=">=1"):
    if expected == ">=1" and produced < 1:
        raise JobProducedNothingError(
            f"{name}: ran fine, produced {produced} effects"
        )
    log.info("%s: effect ok (%s)", name, produced)


重点不在这个辅助函数。重点在于:一个任务现在可以因为「什么都没做」这项罪名而失败了。在此之前,「什么都没做」和「全部做完」是同一个结局——success——这意味着我的成功信号度量的是一个错误的宇宙。

有两件事我必须做对,而我第一次全都做错了:

不会失败的断言不是断言。 我第一版写的是 assert len(rows) >= 0。这是一句穿着安全背心的同义反复。它在空列表上通过了,而我又心安理得地「被保护」了两天。断言必须真的能在你担心的那种情况上失败——这话听起来是废话,直到你凌晨一点亲手写出一个。

断言效果,而不是尝试。 resp.status_code == 200 是对尝试的检查,len(written_ids) > 0 才是对效果的检查。整个事故就是这样一个案例:所有对尝试的检查全部通过,而效果是零。如果你的断言能被一个什么都不做的系统满足,那它就不是断言,是装饰品。

规则二:预期为空不等于意外为空

这里是我差点矫枉过正的地方,也是整个方案里最微妙的部分。

我的一些任务在大多数时候本来就什么都不会做。一个监控 feed 新条目的 watcher,在冷清的一天本来就应该返回零条目。如果我无差别地把「零效果」定成硬性失败,这些任务每天深夜都会狂响警报,结果我就又回到了把告警频道静音的老路——而我早就领教过,被静音的告警频道比没有告警更糟。

所以断言不能是「产出 > 0」,而必须是「数据源被成功查询,*并且*这个空结果值得信任」。这是完全不同的检查:请求成功了、返回了预期的结构、这个空是一个明确且格式完整的空,而不是某个默认值。在我的案例里,破绽其实一直摆在那里:响应缺少分页信封(pagination envelope)。真正的空结果会带着 cursor 和 total 返回,而参数被静默忽略所产生的空,返回的是光秃秃的 []。

实践形态:对于确实可能合法地什么也不产出的任务,对信封结构做断言,而不是对数量做断言。

data = api.get("/filings", params={"from_date": since})
if not isinstance(data, dict) or "items" not in data:
    raise UnexpectedShapeError(f"got {type(data)}: {str(data)[:80]}")
items = data["items"]  # 这里的零没问题——我们已经证明查询被正确执行了


这样一来,「冷清的一天」和「API 不再理会我的请求」就长得不一样了,而这正是全部意义所在。

规则三:三种状态,而不是两种

正是这一步让两类任务都能用上这套方案。现在每次运行都会以三种状态之一收尾,而这三种状态并不是一回事:

  • succeeded_with_effect —— 它把事情干了,附带一个计数。
  • no_work_needed —— 它什么都没干,并且证明了「什么都不干」就是正确答案(上面的信封检查通过)。
  • failed —— 包含 JobProducedNothingError,即它跑过了,但效果的缺失没有得到解释。

只有第三种会触发告警。第二种是正常的、无聊的、预期之中的结果,在简报里只占安静的一行。在做这个区分之前,「什么都没干」和「干成了」无法区分——而我当时已有的告警逻辑,正是为这两者相同的世界调校的。

真正抓到问题的部分

除了最初那个 bug,效果计数器在随后一个月里又抓到了两起静默失败:一起是 token 过期后被静默替换成了匿名的低权限会话(返回 200,结果为空);另一起是路径变更,导致下游任务读取了一个空目录,并写出了一个合法的空文件。两起都没有抛异常,基于状态的监控对它们完全不可见,但两者都在效果曲线上立刻现出一条平线。

我现在打开仪表盘第一眼看的不再是 uptime 或错误率,而是每个任务、每次运行的效果数。一个一直在运行、却永远什么都不产出的任务,不是健康的任务——那是一盏拧掉了灯泡的绿灯。

可以迁移走的部分

如果只让你带走一句话:「没有报错」不等于「干成了」。 你的调度器、编排器和 CI 都会开开心心地为一个什么都没查、什么都没写、什么都没发的进程报告成功,因为这恰恰就是它们被要求去度量的东西。

不管你的技术栈是什么——每晚定时跑的 ETL、webhook 消费者、CI 步骤,还是一个负责对外发内容的 agent——请为每个工作单元挑一个数字:一旦工作静默停止,这个数字就会归零。对它做断言,随时间跟踪它。还要确保你的一部分告警能在平线上触发,而不只是在尖峰上触发。尖峰吵闹,还会自己报信;平线则需要有人专门盯着才能发现。

原文:https://dev.to/samhartley_dev/my-scheduled-agent-ran-40-times-and-did-nothing-heres-the-assertion-that-fixed-it-50g2(作者 @samhartley_dev)

#定时任务 #断言 #静默失败 #监控告警 #Python #自动化