10 月 9 日把每日归档从 T+2 的固定任务改成「前一天审计通过就发」。对外文案是通常次日 02:00 UTC 前,审计未通过的日子次日 13:00 前;这些是出货目标,hold、导出或镜像故障仍会延后。提前发布之后,回填不能再按日历判断哪一天可写,需要读取数据库里的导出水位。
原来的做法
原来的流程是几条独立的定时任务:每天凌晨导出两天前的数据,打包发布到 GitHub Release,提交 manifest,再由镜像任务同步到 R2,客户从 R2 下载。导出前后各有一次审计。
数据库里的数据在导出之前还会被好几条路径修改:价格流的回填(最近若干小时,以及按天兜底的一轮)、盘口从二级采集点回补断连窗口、Predict.fun 冗余采集点的回补,以及 strike 为空的市场的定期重算。这些路径都需要知道「哪一天已经导出了、不能再写」,否则数据库会和已经发出去的文件分叉。原来的答案是日历:比 today - ARCHIVE_LAG_DAYS 更早的日子视为已导出,更新的日子还能写。
原来的日历分界依赖固定归档滞后,也有提前导出限制。改成审计通过即发布后,是否已开始导出要单独记录,不能只从数据日期推出。
09-29:一次断连让整天导出失败
在这之前还有一次故障。09-29 导出 09-27 时,一次 GitHub 连接被对端断开(undici 报 terminated),而导出脚本里的 gh() 没有重试,整次导出崩溃,Release 和 manifest 都没发布,客户侧停在 09-26。
重试按请求类型分别处理:
- 按状态判断是否瞬时:网络错误、超时、429、5xx、secondary rate limit 的 403/429 重试;主限额耗尽和其他 4xx 立即失败;
Retry-After要求等超过 10 分钟就直接失败。 - 创建 draft release 的 POST:限流被明确拒绝时等待后重发;结果不明(比如连接在响应前断了)时只查找、不重发,否则可能建出两个 draft。
- 上传资产:每次重试新开文件流;重试前先按 state、size 和 GitHub 返回的 sha256 digest 对账,已经传完就算成功,否则删掉同名残留再传。
出货编排
新入口 scripts/ship-daily.mjs 每天 00:35 UTC 由 pm2 拉起,按顺序处理已结束、未出货的数据日。scripts/lib/ship-plan.mjs 接收状态和时间参数,返回下一步动作,决策用纯函数测试。对数据日 D:
- 等 D 结束满 35 分钟(尾部回填大约 20 分钟、结算大约 11 分钟);
- 跑 D 的价格回填兜底;
- 等 D 文件里已结束的市场取得结算结果;等待截止设为启动延迟之后 20 分钟,回填耗时也会占用这段时间。到点仍未结算就告警并继续;
- 导出前审计;
- 审计通过,或者到了截止时间(D 结束后 12 小时)仍然是红的,就导出;
- 镜像到 R2,这一步之后客户可见;
- 导出后审计。
几条规则:
- pass 文件。 审计全绿时写文件,开跑时先删旧文件。编排要求新鲜的 pass;既没有 pass 也没有红档算审计崩溃,按次数重试。
- hold 文件。 建一个
logs/ship-hold/<D>,就能扣住这一天的所有发布步骤,包括过了截止时间之后,用于我知道当天数据有问题、正在修的时候。它不阻止回填和审计。 - 每步状态落盘。
logs/ship-state/<D>.json记录每一步的尝试次数和结果,进程崩了重启接着做。所有步骤本身都是幂等的,状态文件读不出来就从头开始。 - 导出一旦尝试过就只能往前。 见下一节,导出会推进水位,推进之后不能再停在「没导出」的状态。
- canary。 系统 crontab 每 10 分钟按 R2 上的 catalog 检查客户实际能看到的最新日期,绿日在 D 结束后 2 小时、红日 13 小时还没上架就告警。
Telegram 改为审计有问题时发消息,绿日只发一条「数据日 D 已归档上架」。上架通知和导出后审计问题保留送达状态,未送达的跨日重试。
导出水位:为什么是一把锁
「哪一天还能写」改由数据库里的一行来回答:export_fence 记一个日期 W,表示「最新一个已经开始导出的日子」。一个日子大于 W 才可写。
只有这一行还不够。导出读一天的数据要发很多条独立的查询:价格、盘口、市场、Predict 的几张表,不是一个快照。如果一条回填在导出已经读完盘口表之后才提交了一行 D 的盘口,这一行就会进数据库、但不进已发布的文件。所以配了一把 advisory lock:
1
2
3
4
5
6
7
8
9
// 导出:排他锁推进 W,提交之后才开始读数据
await client.query('BEGIN');
await client.query('SELECT pg_advisory_xact_lock($1, $2)', EXPORT_FENCE_LOCK);
// ... UPDATE export_fence SET day = $1 ...
await client.query('COMMIT');
// 每条回填:在自己的写事务里、第一次写之前拿共享锁,再读 W
await client.query('SELECT pg_advisory_xact_lock_shared($1, $2)', EXPORT_FENCE_LOCK);
const { writableFromMs } = await readFence(client);
排他锁等待持共享锁的回填事务结束。回填拿到共享锁后,在同一事务的后一条语句读 W;READ COMMITTED 下能看到先前已经提交的水位推进。提交推进后,接入这套检查的回填和 strike 重算不能再写 D。迟到结算仍是例外,后面单独说明。
两个边界:
- 表或行不存在,不能解释成「什么都没导出」。所有写入方遇到这种情况抛
FenceUnavailable拒绝写,直到按真实归档状态把水位初始化好。 - 推进只能一天一天来。导出 D 时如果 W 是 D-1 就推进;D ≤ W 是重导历史日,水位不动;D > W+1 直接拒绝,否则中间那几天会在没导出的情况下被永久封住。
以前那几条按日历判断的分界全部去掉了,回填、二级点回补、Predict 冗余回补、strike 重算都改成在写事务里持共享锁复核。导出本身另加一条:结束不满 30 分钟的日子拒绝导出,编排的启动延迟也因此不能配得比 30 分钟小,配错了直接拒绝启动,不回落默认值。
复审出来的几条
编排后续审查补了这些约束:
- 导出加一把整轮的互斥锁,编排拉起的和手工跑的导出不能同时进行。
- 编排进程丢了自己的单实例锁时,要终止并等待它拉起的子进程,不能自己退出、留下子进程继续写。
- 导出用的暂存目录要在释放导出锁之前删掉,锁的范围要覆盖到清理为止。
- 所有「放弃」类告警,包括导出后审计重试用尽,都要在恢复路径上补发。比如进程恰好在发告警之前崩溃,重启后要能从状态文件看出「放弃了但还没通知」。
- Predict.fun 每天大约有 6 个市场(3 个日级、3 个 00:00 开盘的小时级)在 D 导出开始时还没结束,D 日文件里
end_price为空,以第二天的文件为准。冗余回补把它们算成 D 的损失是误报,改成这种情况只豁免end_price。
边界和遗留
网站文案中的 13:00 给 12:00 截止之后的导出和镜像留出时间。编排在截止时开始导出,并不能保证故障或重试时仍在 13:00 完成;canary 会检查客户可见的 catalog。
水位也有管不到的地方。迟到的结算(finalizeMarket)不受水位约束,因为库里的结算标签必须正确;如果它发生在导出之后,库和已发布的文件就会不一致,这种情况发 Telegram 提示,在热数据保留的 3 天内重导那一天。
另一个已知问题记下了但决定不修:一天的热数据被 prune 之后再重导,导出没有护栏,会把 Release 上完整的价格和盘口资产换成空的或零星几行(R2 那边有缩水拒绝,旧对象也不删)。现在只在运维说明里写明 prune 之后别重导该天。
10-11 又给镜像加了前置钩子:写 catalog 前调用 Parquet 转换,上限 40 分钟,无论成败 catalog 都照常发布。钩子成功时,两种格式可同时可见;失败或超时则先发布 gz,由定时任务补转。运维笔记记录,这一步使出货约晚 15 分钟。
outcometick 工程笔记(18/21)