这周(09-13 到 09-15)碰到两个问题:网站 API 的成交通知失败后没有重试,采集侧两个归档进程同时操作同一个 git clone。
一条消失的成交通知
09-13 凌晨 2 点(北京时间),一笔稳定币订单正常结算:权限开通了,key 签发了,买家 6 秒后就在付款页上确认收到了。但发给我的 Telegram 成交通知没有送达。
数据库里看不出问题。订单行完全正常,/health 里卡住的订单数是 0,「已交付但没通知」也不在任何巡检里。唯一痕迹是 error 日志里的一行:
1
alert send failed: fetch failed (ECONNRESET)
发送失败后没有重试
这次连接重置后没有重试。告警统一走 makeAlerter 生成的函数,原来的实现失败时只打一行日志、返回 false。调用方式和事件源还有几个限制:
- 调用点几乎都是 fire-and-forget,不 await 返回值。轮询器里的成交通知、「收到一笔对不上订单的钱」、「反复开通失败」三条都是;
- 触发告警的事件源本身是幂等的或一次性的:
applyDeposit对同一笔充值只处理一次,有的告警有warned集合去重,有的只在第 3 次失败时发; - 发送失败后,事件源不会再次触发这条通知。
因此这条通知没有补发。同一条路径上还有「钱到了但对不上订单」的告警,也会受到影响。
重试放在哪里
重试放在 makeAlerter 内部,不放在调用点。每个调用点都有同样的问题,而且最要紧的那两条(钱收了对不上单、钱到账开不出权限)恰好都是不 await 的,改调用点要改一片,还会漏。
规则是只重试瞬时失败:
- fetch 直接 reject(连接重置、DNS 失败、超时)、429、5xx:退避 1s / 3s / 9s,最多四次,总共十几秒,比 30 秒一次的轮询短得多;
- 400 / 401 / 403 / 404 立即返回 false。token 换了、bot 被拉黑,问多少次都是同一个答案,重试只会把每个 await 它的调用方拖住十几秒。
返回值的含义不变,仍然是「有没有送达」。有的调用方持有一个事件的租约,没送达要把租约放回去,靠的就是这个返回值。
日志也改了:fetch 失败时 e.message 只有字符串 "fetch failed",错误码在 e.cause.code 里(ECONNRESET、EAI_AGAIN、ETIMEDOUT)。这次能查到连接重置,是因为之前已在日志里记录 cause.code。
1
2
3
4
5
6
7
8
9
10
11
return async (m) => {
for (let i = 0; ; i += 1) {
const r = await attempt(m);
if (r.ok) return true;
if (!r.transient || i >= retryDelaysMs.length) {
error(`alert LOST after ${i + 1} attempt(s): ${r.why}`);
return false;
}
await sleep(retryDelaysMs[i]);
}
};
sleep 和 retryDelaysMs 可注入,测试里不需要真的等十几秒。
差点让部署回滚的测试
复审时还发现一个差点让部署回滚的测试问题。
测试里有一条「没配置 Telegram 时只打日志」,我屏蔽配置的写法是:
1
2
delete process.env.TG_BOT_TOKEN;
delete process.env.TG_ADMIN_ID;
看起来没问题,本地跑 6/6 全绿。但项目的 config.mjs 是把 api/.env 垫在 process.env 下面读的:
1
process.env[k] ?? fileEnv[k]
delete 之后是 undefined,于是回落到文件里的值。我本地的 api/.env 没有这两个键,所以绿了;生产机的 api/.env 有这两个键,而部署脚本 deploy.sh 会在生产机上跑全套测试。结果就是:在生产上,这条「未配置」的测试会构造出一个真正配置好的 alerter,断言失败,部署自动回滚。
我在本地往 api/.env 里追加了这两行再跑,确认了确实会红。改成赋空串就好了:空串不是 nullish,?? 不会回落,能盖住文件里的值。
1
2
process.env.TG_BOT_TOKEN = '';
process.env.TG_ADMIN_ID = '';
改完做了三次变异验证:去掉重试(3 条红)、改成所有失败都重试(1 条红)、用 delete 版在生产形状的 .env 下跑(1 条红)。
09-13 这一天没有归档
两天后,09-15 早上的归档巡检报了三条红,都是关于 09-13 这一天的:
- I:klines_1d 缺当天,0/8;
- J:镜像落后 1 天;
- J:缺 09-13 的 catalog。
三条报错其实都指向同一件事:09-13 的归档根本没有发布。I 的文案写着「发布后 commit 失败」,反而会误导排查,因为这次什么都没发布。
两个进程,同一个 clone,同一秒
先说一下流程。采集机每天凌晨跑一次导出(export-daily.mjs),把前天的数据打包发布,并把 manifest 提交到一个归档 git 仓库的本地 clone 里。另一个任务 mirror-to-r2.mjs 从 02:00 UTC 开始,大约每 61 秒对同一个 clone 执行一次 git pull --ff-only,等当天的 manifest 出现后再把文件镜像到 R2。客户下载读的是镜像那份。
导出在 02:26 到 02:30 之间也会对这个 clone 做 git pull --ff-only(loadPreservedRetiredEntries 里)。翻两份日志,同一秒 02:27:31:导出报了
1
fatal: Cannot fast-forward to multiple branches.
镜像那边报「已同步到」。导出在发布之前就崩了,镜像一直等到超时。
git fetch 会先清空 .git/FETCH_HEAD,再向里面追加。两个 fetch 并发时,FETCH_HEAD 会留下两条 branch 'main'。后读取它的 pull 以为需要合并多个分支,于是拒绝 fast-forward。
这个窗口每天晚上都存在。镜像的「等待 manifest」逻辑是 09-04 加的,每天撞上的概率大约 1% 到 2%,11 天后第一次撞上了。镜像那边的代码注释只考虑了「自己会看到导出做到一半的中间状态」(工作区是脏的),导出这一侧完全没有保护。
没丢数据
清理任务对没有 manifest 的日子是 fail-closed 的,不会删,09-13 的原始行都还在数据库里。影响是客户取不到那一天,rolling 窗口的锚点停在 09-12。
补救:手工跑一次 node scripts/export-daily.mjs 2026-09-13(导出是幂等的,任意过去的日子都可以安全重导),再让镜像 --days 3 跟上。
修法:flock
只让镜像任务容忍 git 失败,仍然可能中断导出,所以给两个进程的 git 操作加了锁:
1
2
3
4
5
export function lockedGit(archiveDir, args, opts = {}) {
return execFileSync('flock',
['-w', '300', path.join(archiveDir, '.git', 'clone-ops.lock'), 'git', '-C', archiveDir, ...args],
{ encoding: 'utf8', ...opts });
}
用 util-linux 的 flock 包住整条 git 命令,锁跟着文件描述符走,git 被 kill 掉锁也会释放。锁文件放在 .git/ 下面,不会出现在 git status 里。
复审时又发现还有第三个进程会碰这个 clone:交付用的 Telegram 机器人,它的 /days 和 /link 命令任何时候都可能 pull 一次。于是导出、镜像、机器人三处全部改成走 lockedGit。
测试写了两类。一类复现竞争:建一个裸仓库和一个 clone,起两个子进程各连续 pull --ff-only 12 次。不加锁时在我本机稳定失败,线上两个任务只有几分钟重叠,所以此前没有撞上。另一类是对照组:两个不加锁的子进程各 sleep 0.4 秒,断言它们的时间区间确实重叠,排除机器太慢、实际没有并发而让加锁测试通过的情况。最后再扫一遍三个文件的源码,禁止出现不经过锁的 execFileSync('git', ...)。
连带:回测的预热也少了一天
回测 worker 每天 05:05 左右按镜像 catalog 做预热(把市场日提前解码进本机缓存)。镜像晚到那天,它只热到前一天,漏掉的那天要等第二天的预热。补的办法是在 worker 上手动 systemctl start outcometick-prewarm.service。这个服务是 Type=oneshot,start 会一直阻塞到跑完,所以要放到后台。实测补一天:Polymarket 109 分钟,Predict 17 分钟,0 失败。
测试里补了生产形状的 .env 和不加锁的并发对照组。
outcometick 工程笔记(14/21)