一个 Python 评论评审管线限制每条评论最多调用模型三次,但两个入口并发操作同一批文件时,原子 JSON 写入仍无法保障重试预算。作者用真实子进程和记录调用次数的假模型,复现并发与结果保存失败带来的漏洞。
最初发表于 hexisteme notes。
我的评论处理流水线可以为 dev.to 评论附上一份模型判定:一个简短的结构化 JSON 判断,存储在评论旁边。有时判定会失败,比如模型返回了格式错误的 JSON,或者 provider 过载。遇到这些情况,流水线可以复用缓存的 prompt,稍后重新检查。不过,它有一个次数预算:每条评论最多尝试三次,首次尝试也算在内。
这条流水线只有一份作为唯一实现的 Python watcher。Claude 侧的入口直接运行它,Codex 侧的入口则通过一个桥接程序执行同一份代码。这一点很关键,因为两个入口可能同时操作同一批文件。
当我开始检查并发问题时,watcher 的每一次文件写入都已经是原子的:先写临时文件,再替换原文件,因此任何读取方都不会看到只写了一半的 JSON 文档。但事实证明,这并没有保护我真正关心的东西。
对缓存的失败判定进行本地重新检查时,存在并发和崩溃方面的漏洞。为了准确复现,回归测试使用了真实的子进程,以及一个会记录每次调用的模拟模型。
第一种情况是,模型已经返回答案,但保存已完成的判定失败了。第二种情况是,负责调用模型的进程被终止了。两种情况下,都有第二个重新检查命令排在第一个后面等待执行。两次测试得到的调用日志都是 ['first', 'second']。排队的命令读取磁盘,发现状态仍然和第一个命令开始时一样,尚未完成,于是再次调用了模型。
这两种失败都不需要文件写到一半。即使每个文件都是完整、有效的 JSON 文档,次数预算仍然会出错。
原子替换保护的是某个时刻的单个文件。而我需要保护的是一连串操作:读取尝试次数,调用外部模型,然后写入判定结果,再更新另外两个文件。模型调用根本不在磁盘上,而三个文件分别进行原子写入,并不构成一个事务。
只有保存结果时,这次尝试才会在磁盘上留下记录。因此,调用与保存之间的任何失败,都会抹掉调用已经发生的证据。原子性保证了第二个进程读到的数据格式完整,却丝毫不能保证数据是最新的。
修复方案把次数记录移到了调用之前,并用一把锁保护整条流水线。
现在,扫描和重新检查共用一把流水线锁,唯一实现的入口和桥接入口都会获取这把锁。命令先记下自己准备处理的缓存状态,等待获取锁,然后重新读取。如果等待期间,另一个命令改变了这条评论的失败状态,它就推迟处理,而不是根据过期状态继续执行。
在联系 provider 之前,针对缓存的重试会先在该评论的 inbox 文件中,以原子方式预留下一次尝试,同时保留 prompt 和此前的错误历史。这次预留本身就计入尝试次数。如果无法写入预留记录,就不会调用模型。
如果负责这次调用的进程随后终止,或者完成结果无法保存,这次已经计数、尚未完成的尝试仍会保留在磁盘上。下一个排队的调用会将它记录为错误,并且不会在这次执行中调用模型。之后显式发起的重新检查,可以在剩余预算内再次尝试。如果判定已经完成并且持久化保存,后续运行会根据这份结果修复其他文件中的状态,无须再次调用模型。
共享的 “seen” 状态也有了自己的一把锁,只在短时间内持有。保存时会重新读取文件,并且只合并当前调用方相对于最初读取版本实际修改过的字段。其他命令写入的已回复和已忽略状态、新发现的评论以及尝试次数,都会保留下来。尝试次数永远不会减少。显式的状态变更,例如将评论标记为已回复,会以补丁形式应用;即使该变更与某次旧读取中的状态相同,它仍然具有优先效力。
从概念上说,针对缓存的重试现在按以下顺序执行。这只是示意,并非 watcher 的实际代码:
take the pipeline lock (wait at most 5 s, else exit with an error)
reread this comment's state
if attempts used >= the saved cap: stop and report it as exhausted
durably reserve and count the next attempt
if the reservation failed: stop, no model call
call the model once
save the verdict (if this fails, the attempt stays counted)
周边的限制刻意设计得很朴素。每次获取锁最多等待五秒,超时会报错并以非零退出状态结束,而不是悄悄跳过。每次执行中,每条评论最多调用一次模型。一个批次默认处理五条评论,硬性上限是二十条。第一次收到限流响应 HTTP 429 时,就停止整个批次。外面没有再套一层重试循环。
一组专门针对并发和恢复的 38 项测试全部通过,其中两类各有 19 项。完整的 watcher、autoreply 和 reply 测试套件共通过 223 项测试,分别为 140、64 和 19 项。这些套件与专项测试存在重叠,所以我不会把这些数字相加,假装它们代表互相独立的覆盖范围。离线检查也没有改变真实的观测日志:逐字节完全一致,大小仍为 97,094 字节。
之后,我对实际存在的三条缓存失败记录进行了线上恢复:检查了三条,恢复了三条,每条各调用一次模型。再次运行同样的重新检查,没有产生任何额外调用。这只是一个范围有限的小规模证据,并非负载测试。
每次写入各自都是原子的。但它们仍然不是跨文件事务,整条流水线也没有实现 exactly-once。
预留机制采取了保守策略。如果进程在预留之后、请求到达 provider 之前崩溃,这次尝试仍然会被消耗。这可能会消耗一次实际未使用的尝试,但在已测试的崩溃和持久化失败场景中,它能确保这些针对缓存的重试始终被计数。
预留机制只覆盖针对缓存的重试。首次扫描一条新评论时,如果在第一次持久化写入 inbox 之前被中断,仍然可能重复最初的模型调用。而从未保存过的模型响应也就丢失了;这里的机制无法恢复它。
这些锁依赖各方协作。它们保护的是通过 watcher 自身函数写入文件的进程。直接覆盖这些文件的进程会绕过锁。
背后的思路与《上传成功了,记录却没保存》一文相同:那个案例中的上传阶段,直到验证完成后才记录结果,导致重新运行时重复上传。它需要在后续验证之前,先记录返回的上传 ID。这里则是在调用模型之前,先记录尝试预留。两种记录描述的是不同阶段,但都不会只留在内存里,等整条流水线成功后才落盘。原子写入让每条记录格式完整,却不会替你决定何时写入。
这些笔记的邮件订阅地址是:hexisteme.beehiiv.com。目前还没有发出过任何一期,所以现在订阅,你会在第一期发出之前就加入。没有欢迎邮件序列,没有课程,也没有追加推销。
更多笔记见 hexisteme.github.io/notes。
部分评论可能仅对已登录的访客可见。登录即可查看所有评论。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。