Posts 一次 ECONNRESET 丢掉的成交通知,和两个进程同一秒 git pull
Post
Cancel

一次 ECONNRESET 丢掉的成交通知,和两个进程同一秒 git pull

  这周(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。调用方式和事件源还有几个限制:

  1. 调用点几乎都是 fire-and-forget,不 await 返回值。轮询器里的成交通知、「收到一笔对不上订单的钱」、「反复开通失败」三条都是;
  2. 触发告警的事件源本身是幂等的或一次性的:applyDeposit 对同一笔充值只处理一次,有的告警有 warned 集合去重,有的只在第 3 次失败时发;
  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)

上一篇:Predict.fun 的结算判据和平局:一个冻结的 status 字段,和一个第三种结果

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

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

Predict.fun 的结算判据和平局:一个冻结的 status 字段,和一个第三种结果

pmwallets 起步:只信链上日志的 Polymarket 实体账本