Posts 付费回测失败没有告警:超时分类、回放预算和重排队
Post
Cancel

付费回测失败没有告警:超时分类、回放预算和重排队

  09-23 查生产回测记录时,发现付费 run 的超时失败一直没有通知。失败会自动全额退回 credits,成交有 💰 通知,失败没有。

  当时核查确认,9 个真实客户 run 的回放墙钟超时被报成了喂数据失败,另外还有取数预算耗尽的情况。

超时被报成了 EPIPE

  这批 run 是 worker 主动触发的墙钟 kill,但写给客户的错误是:

1
E_RUNTIME could not feed the archive to the runner: write EPIPE

  有一位买家因此把同一个策略原样重交了四次。

  原因在判断顺序。原来的链是 rejection → streamError → killed。这批超时发生在喂 stdin 期间,容器被杀后管道断开,streamError 抢先把错误归成了取数管道故障。

  改成 rejection → killed → streamError → 退出码。原来这段逻辑内联在 executeRun 里,我把它抽成 sandboxFailure,直接测试多种错误同时出现时的分类;另加源码扫描,禁止 executeRun 再内联一份 if (sandbox.killed)。

  也补了容器退出时的诊断。worker 用 --rm 跑容器,事后无法再 inspect。现在容器存活期间采样 cgroup v2 的 memory.events 中的 oom_kill 和 memory.peak,启动后立即解析容器信息,不等首个采样周期。

  这仍不能可靠检出 OOM:容器退出后 cgroup 会被清掉,10 秒一次的采样容易错过最后那段。memory.peak 只是已观测到的下界;退出码、信号和 worker 是否主动 kill 则保留下来。

为什么会超时:一个按 Polymarket 标定的系数

  旧墙钟预算按每市场日 35 秒算,这个数在 polymarket + nodejs 上标定,空策略实测约 24 秒。09-23 又测了 predict 同一市场日、两种语言的空策略,连同旧基准列在下面:

平台解码后每行每事件每市场日
polymarket / nodejs134 字符23 µs约 24 s
predict / nodejs2060 字符80 µs39.3 s
predict / python2060 字符274 µs134.6 s

  Predict.fun 的解码盘口事件是整本两侧快照,占该样本解码字符的 98.7%;Polymarket 盘口增量则按单侧变化解码。08-25 predict 改为全量采集后,同一数据日的事件数约增至三倍,预算系数没有跟着改。按这次 Python 空策略约 135 秒/市场日估算,两个市场日已需约 270 秒,超过旧预算 180 + 35 × 2 = 250 秒,报价却仍接受这样的范围。

  改成按(平台, 语言)查表,MAX_REPLAY_MS 保持 108 分钟,即旧的 180 × 35s + 3 分钟。单次可售上限先扣除 3 分钟基础预算,再除以系数取整:

1
2
3
4
polymarket nodejs  35s → 180 市场日(不变)
polymarket python 120s →  52     (按 3.4 倍比例推算,未实测)
predict    nodejs  55s → 114
predict    python 180s →  35     (原来的售卖上限是 180)

  上表是 09-23 首版配置;当天后续提交又把 polymarket/nodejs 调到 65 秒,上限变成 96 市场日。polymarket/python 的 120 秒是推算值,10-07 才按生产成功 run 重标。报价缺少语言时按该平台较慢的语言估算。

两台机器、两次部署

  改系数引出了一个部署问题。API 和 worker 在两台机器上,分开部署。如果 worker 每次自己重算预算,改一次系数就可能出现「报价按 A 收钱,worker 按 B 掐断」。所以 createRun 把算好的预算写进 backtest_runs.replay_budget_ms,worker 优先读这一列,读不到才回落。

  部署顺序因此必须是 worker 先。我算过两个方向的最坏情况:worker 先,最坏让一个 180 市场日的 run 少 60 秒,而那个尺寸本来就跑不完;API 先,新 API 卖出 108 分钟的 run,旧 worker 按旧系数在 23 分钟处杀掉,差 5,075,000 毫秒。

  deploy-worker.sh 加了兼容守卫,也检查回滚目标。一个保存了 6,480,000 毫秒预算的 run,如果交给不读该列的旧 worker,可能按旧算法在 1,405,000 毫秒处被杀掉。

  第一版按源码里有没有 replay_budget_ms 判断,注释里出现这个名字也会通过。第二版把目标提交检出到一次性 worktree,import 它的 replayBudgetFor,输入 4321987,要求原样返回。

  后续复审发现 worktree 检查有问题:它没有 node_modules,而 runner/worker.mjs 的 import 图经过 api/lib/backtest-db.mjs 走到了 pg,会报 ERR_MODULE_NOT_FOUND。这种拒绝不能证明旧 worker 不读取预算,也会误拒兼容的版本。

  修法是把 replayBudgetFor 挪进 runner/replay-budget.mjs,它只 import api/lib/backtest-contract.mjs,后者只 import 一个本身没有任何 import 的文件。守卫加载这个模块。测试从它出发递归走 import 图,遇到任何裸包名(非 node: 内置、非相对路径)就判红;临时加一句 import pg from 'pg',当场变红。

取数阶段的超时改成重排队

  取数阶段也会超时:解码一个冷的市场日要好几分钟,预算耗尽就失败退款。一次失败已经把部分解码结果写进本机缓存,同一区间重新提交,耗时从 60 分钟降到 13.9 分钟。原来的流程需要客户再提交一次才能用上这些缓存。

  现在取数预算耗尽、至少一半天数已入缓存且尝试次数未到上限时,run 重新排队,credits 保持 held。判据用 fetchMarketDays 返回的 bankedDays:onDay 对 abort 之后跳过的每一天也会递增,不能拿它当缓存计数。缓存命中算一票,冷解码则要 writeDecodedDay 返回 true 且回读确认;一天里的各 asset group 都成功才算这一天已入缓存。

  重排队还暴露了所有权守卫的问题。原来的 run.worker_id && run.worker_id !== workerId 在 worker_id 为 NULL 时不成立。可能出现这样的顺序:A 领取后租约过期,B 接管并重排队,A 从阻塞操作返回调用 failRun。旧守卫会放行 A,把 B 安排的重试改成失败退款。现在 ownsRun() 在传入 workerId 时要求精确相等,状态还必须是 leased 或 running。

失败通知

  客户 run 失败的路径补了 Telegram 通知。

  写的时候发现 runner/worker.mjs 里有一个 alerter 只用了一处、从未声明。ESM 严格模式下对未声明的标识符求值会抛 ReferenceError,可选调用 alerter?.() 挡不住。于是「策略往结果通道写未认证行」这条安全分支,实际行为是抛异常被外层 catch 接住,客户收到的拒绝信息是那句 ReferenceError,我这边一条告警都没有。

  通知覆盖 executeRun 的拒绝、tick catch 到的崩溃,以及 leaseRun 的尝试次数用尽。最后一条在函数内部失败并退款,然后返回 null,调用方无法从返回值区分它和空队列。加的 onGaveUp 回调在 COMMIT、归还连接后调用,并捕获通知异常。

  为避免同一个提交反复失败时刷屏,通知按 subject + 错误码限频,窗口内其余失败累计计数。后续修了几处:

  1. 告警没有请求超时,而 worker 在结算路径上 await 它,一个被接受但永不作答的连接会让唯一的 worker 停止领取任务。每次请求加 AbortSignal.timeout(8000),通知整体再加 20 秒上限。
  2. 限频表只清过期条目不构成上界,足够多不同的 subject 同时失败时照样涨。改成先清过期、仍超限就淘汰最旧的。写这条时又发现容量检查跑在每次调用开头,查询一个已存在的 key 也会先触发淘汰,而稳态下最旧的往往就是被查的那个,一个反复失败的客户会不停把自己淘汰掉、每次都发通知。现在只在真要占一个新槽位时才强制容量。
  3. 窗口在发送之前就开了,而 sender 返回 false 也被当成已发。Telegram 抖一下,就同时吃掉第一条告警和之后十分钟内该客户同类失败的全部提醒。改成只有 sender 明确返回 true 才开窗口;超时按未送达算,宁可多一条重复。
  4. 未送达的那次没有计入累计,故障期间的失败在恢复后的第一条消息里不留痕迹。现在被压掉的和没送到的同样计数,下一条带出「此前 N 次同类失败未单独通知」。

  内部验证的 test:、e2e: subject 不报警,它们经常故意失败。第一版还把 offline: 放进了静默名单,后来确认它是交付机器人签给线下客户的 key,随即移除。按付费渠道区分客户时,线下签发的 key 也要算进去。


outcometick 工程笔记(16/21)

上一篇:一个人做 outcometick:预测市场数据订阅与回测的三个多月

下一篇:discovery 静默停摆后的盘口回补和二级采集点

This post is licensed under CC BY 4.0 by the author.

榜单第一次发出来之前:漏掉的抵押品、回退的 ASOF join 和 ClickHouse 内存

discovery 静默停摆后的盘口回补和二级采集点