Posts 归档从 T+2 提前到次日:出货编排与导出水位
Post
Cancel

归档从 T+2 提前到次日:出货编排与导出水位

  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:

  1. 等 D 结束满 35 分钟(尾部回填大约 20 分钟、结算大约 11 分钟);
  2. 跑 D 的价格回填兜底;
  3. 等 D 文件里已结束的市场取得结算结果;等待截止设为启动延迟之后 20 分钟,回填耗时也会占用这段时间。到点仍未结算就告警并继续;
  4. 导出前审计;
  5. 审计通过,或者到了截止时间(D 结束后 12 小时)仍然是红的,就导出;
  6. 镜像到 R2,这一步之后客户可见;
  7. 导出后审计。

  几条规则:

  • 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)

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

下一篇:盘口节流的丢帧与合并:存储量、重建误差和回测修正

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

从全量重算到增量 PnL:按天事实、晚到的映射和被限流的 CPU

盘口节流的丢帧与合并:存储量、重建误差和回测修正