4 个 worker 30 次重试:Athena 上传队列的持久化窗口漏在哪

作者:

在

结论:Athena 的按远端请求上传用「4 个 worker + 优先队列 + Params 快照」实现,但它不是一张事务表:入队写盘、出队、取消、过期这些时点的快照并不一致,取消与过期分支都不会刷新快照,进程重启后已取消或已过期的任务可能重新出现。重试上限是 30 次、每次最长等待 10 秒,而 401/403/412 被归入终止分支、日志里也记为 success,不能当作远端已存储证明。以下为固定源码快照 c8fb906 的静态分析与隔离探针结论。

队列与四个 worker

这里讨论的是 Athena 按远端请求上传指定文件的队列,不是 loggerd/uploader.py 的常规路段上传。upload_handler() 由四个线程共享一个优先队列,取任务后依次处理取消、31 天过期、计费网络判断、PUT 响应与异常;线程数由 handle_long_poll() 固定创建四个,不受 RPC 的 HANDLER_THREADS 配置影响。main() 进程启动时只恢复一次队列,每次 WebSocket 会话创建上传线程,重连成功后清空当前任务字典。

UploadFile、UploadItem 负责 RPC 参数映射与队列任务;UploadItem.__lt__/__eq__ 只比较 priority,不以 ID 比较。因此优先队列不会因此去重,同优先级的 FIFO 顺序也没有由任务 ID 或入队序号明确保证。

组件 入口 职责和边界
UploadItem athenad.py RPC 参数映射与队列任务;比较只按 priority,不按 ID,队列不去重。
UploadQueueCache athenad.py 启动从 Params AthenadUploadQueue 恢复;只快照等待队列并排除已取消 ID。
uploadFilesToUrls() athenad.py 校验文件名与存在性、按 URL 去查询串去重、生成 ID、入队并写快照。
upload_handler() athenad.py 四个线程共享优先队列,处理取消、31 天过期、计费网络、PUT 响应与异常。
retry_upload()、cb() athenad.py 计数小于 30 时重排并持久化,随后最长等待 10 秒;回调只在状态年龄大于 1 秒时检查计费。
listUploadQueue()、cancelUpload() athenad.py 列表合并等待与当前任务;取消只标记等待项,不中止当前传输,也不立即刷新 Params。

入队去重与任务 ID 的真实边界

uploadFilesToUrls() 会检查文件名与存在性:拒绝空名、绝对路径与包含 .. 的名字,但没有证明符号链接与真实路径受限。去重比较的是 file.url.split('?')[0],覆盖等待与当前列表,未比较文件内容、完整 URL 或 headers。它只防止入队当时可见的相同基础 URL。

任务 ID 用包含创建时间的 str(item) 做 SHA-1,这不是内容摘要,也不是服务端事务 ID。因此这套去重与 ID 都不能充当跨重启或并发请求的幂等键——重启后旧快照可能把任务恢复回来,而同一文件换个查询串就会被当作新任务。

结果码、重试与等待

上传结果被分成两类:响应码为 200/201/401/403/412 时终止任务、不再重试;其他 HTTP 状态以及 Timeout/ConnectionError/SSLError 计入重试。retry_upload() 在当前 retry_count < 30 时重新排队与持久化,随后最长等待 10 秒;计数初始 0 可重排为 1,29 可重排为 30,已经是 30 时不再重排。

计费网络与重连中止走的是不增计数分支:调用 retry_upload(..., False),符合上限判断才重排并写盘,但每次都仍按最长 10 秒等待。_do_upload() 在请求的 .zst 不存在而扩展名去掉后的原文件存在时,会边压缩边上传,使用请求提供的 URL 与 headers,覆盖 Content-Length,PUT 超时 30 秒。总体而言,名为 success 的日志事件包含 401/403/412,不能当作远端已存储的证明。

结果 是否重试 说明
200/201/401/403/412 否,终止任务 HTTP 返回后写等待队列快照;success 日志含 401/403/412,非远端存储证明。
其他 HTTP 状态 是 计数加一后重排并写盘;计数 30 时不再重排。
Timeout/ConnectionError/SSLError 是 同可重试失败路径,计数达到上限后无额外快照更新。
计费网络/会话退出 中止但不增计数 调用 retry_upload(..., False),仍按最长 10 秒等待;进入时已达 30 则不重排。

持久化窗口

UploadQueueCache.cache() 的 block=True 只要求 Params 写入调用阻塞完成,不是远端上传回执,也没有把「等待、传输中、完成、失败」作为单个原子状态持久化。listUploadQueue() 是瞬时内存视图;缓存与列表分别直接访问优先队列内部数组,不能视作同一事务快照。

隔离探针复现了几个必须知道的窗口:等待项已有快照后调用 cancelUpload(),内存列表隐藏该 ID,但 Params 中仍是旧快照,模拟重启后会重新进入等待队列;retry_upload() 先重排与缓存、再清 cur_upload_items[tid],在缓存调用期间查询列表可暂时看到同一 ID 的「等待」和「当前」两项。探针没有并发调度、真实持久化故障或 HTTP 请求,证明的是允许这些状态的局部执行顺序。

路径 Params 快照时机 可观察结果
入队 循环结束后写一次 RPC enqueued 表示入队,不是上传确认;多任务入队与写盘非原子。
出队及上传中 出队时不写快照 原快照可能仍含该项;中断后重放既不能保证也不能排除。
成功分支 HTTP 返回后写等待队列快照 当前字典直到下轮才置空,列表可能短暂显示已结束项。
可重试失败 先入队再写快照,HTTP 分支随后再写一次 计数可 0→1、29→30;已是 30 则不再重排。
取消/过期 两个提前 continue 分支都不刷新快照 旧快照可能仍有该项,进程重启后可能重新出现。
其他异常 没有统一任务终结/缓存动作 是否重新出现取决于此前快照与其他线程写盘,不应推断为可靠重试。

自制硬件与插件重构要求

源文档给出一组建议:把上传置于非实时服务域,与 CAN/执行器控制隔离;文件能力使用目录 FD 或真实路径约束,目标 URL、重定向与 headers 用显式策略,不能把远端 RPC 参数直接授予任意插件。任务应定义 queued / leased / transferring / accepted / rejected / retry_wait / exhausted / cancelled / expired 等可持久状态,出队先记录租约、超时回收,并用单一事务或追加日志保存状态迁移。

幂等应以服务端任务 ID 或文件内容摘要加目标对象键实现;重试需退避、抖动、总期限与错误分类,401/403/412 应有各自的鉴权/权限/前置条件结果,不能统一归为成功。取消 API 必须覆盖等待与传输中任务并持久化取消终态。验收要用离线测试服务与故障注入:四线程并发、每个持久化时点断进程、重复 RPC、计费网络切换、重连、缺失文件、符号链接、HTTP 拒绝与重试耗尽。

常见问题

RPC 返回 enqueued 是不是就说明文件已上传?

不是。入队只在循环结束后写一次 Params 快照,enqueued 表示入队成功,不代表上传确认,多任务入队与写盘也不是原子事务。

上传重试最多几次,间隔多久?

重试上限是 30 次:retry_upload() 在 retry_count < 30 时重排,已是 30 则不再重排;每次重排后最长等待 10 秒。计费网络或会话退出中止时不增加计数,但仍按最长 10 秒等待。

日志里出现 success 就等于远端存好了吗?

不等于。名为 success 的日志事件包含 401/403/412,这些是终止分支的响应码;它们不构成远端已存储的证明,需要服务端回执另行确认。

为什么重启后已取消的任务又出现了?

因为取消与过期这两个提前 continue 分支都不刷新 Params 快照,旧快照可能仍包含该项;模拟重启时清空内存取消集并执行 initialize(),任务会重新进入等待队列。

延伸阅读

参考资料

  • 项目 02 —— openpilot-research/ATHENA_UPLOAD_QUEUE_PERSISTENCE_RETRY_DEEP_DIVE.md(Athena 上传队列、四个 worker、结果码与持久化窗口)。
  • 项目 02 —— openpilot-research/ 同目录探针记录:四项队列快照与五项上传回调计费门隔离探针结论。

说明:本文为对固定源码快照的静态结构分析,未运行完整测试套件、未连接硬件、未验证目标车型的真实参数组合。文中所有阈值、行号索引与配置均出自源文档,标注为未验证的结论保留其原有边界。

更新记录

首次发布 2026-10-11 最近核对:2026-10-11

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注