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 / nodejs | 134 字符 | 23 µs | 约 24 s |
| predict / nodejs | 2060 字符 | 80 µs | 39.3 s |
| predict / python | 2060 字符 | 274 µs | 134.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 + 错误码限频,窗口内其余失败累计计数。后续修了几处:
- 告警没有请求超时,而 worker 在结算路径上 await 它,一个被接受但永不作答的连接会让唯一的 worker 停止领取任务。每次请求加
AbortSignal.timeout(8000),通知整体再加 20 秒上限。 - 限频表只清过期条目不构成上界,足够多不同的 subject 同时失败时照样涨。改成先清过期、仍超限就淘汰最旧的。写这条时又发现容量检查跑在每次调用开头,查询一个已存在的 key 也会先触发淘汰,而稳态下最旧的往往就是被查的那个,一个反复失败的客户会不停把自己淘汰掉、每次都发通知。现在只在真要占一个新槽位时才强制容量。
- 窗口在发送之前就开了,而 sender 返回 false 也被当成已发。Telegram 抖一下,就同时吃掉第一条告警和之后十分钟内该客户同类失败的全部提醒。改成只有 sender 明确返回 true 才开窗口;超时按未送达算,宁可多一条重复。
- 未送达的那次没有计入累计,故障期间的失败在恢复后的第一条消息里不留痕迹。现在被压掉的和没送到的同样计数,下一条带出「此前 N 次同类失败未单独通知」。
内部验证的 test:、e2e: subject 不报警,它们经常故意失败。第一版还把 offline: 放进了静默名单,后来确认它是交付机器人签给线下客户的 key,随即移除。按付费渠道区分客户时,线下签发的 key 也要算进去。
outcometick 工程笔记(16/21)