Posts 把账本回填到 08-14:已知的坏形状、512MB 字符串上限和一张只写不删的副本
Post
Cancel

把账本回填到 08-14:已知的坏形状、512MB 字符串上限和一张只写不删的副本

  pmwallets 上线时,实时链路只覆盖最近十来天,榜单的 30 天窗口填不满。09-24 开始用 QuickNode 的 rpc-scan 往回补,目标是 08-14 00:00 UTC(块 91,973,715)。到 09-27,ingest_blocks 最早一块就是 91,973,715,缺失高度为 0。这篇记回填过程中的几件事,以及同一天上线的按钱包读成交的副本表。

一天一天补,每天体检

  deploy/backfill-daily.sh 的注释估计,连续扫描 28 天约需 56 小时。逐日驱动可以每天检查一次结果:

  • 每天的范围从账本自己推(to = min(block) − 1),中断后续跑不依赖额外的游标;
  • 开工前和写完后都检查全账本连续。只查当天接缝不够:rpc-scan 在自己的区间里是从低往高写的,扫 [A, B] 的第一批就把 min(block) 拉到 A,此时 [A+2000, B] 还空着,按 min − 1 往下推会在一个 38 万块的洞上继续盖,接缝检查每次都会通过;
  • 每天跑完做体检:成交数、invalid 组数、未知发射方的日志数、问题形状(把数字规整成 N,一种形状一行),以及 cash 偏差是否仍然全是 −1。出现没见过的形状,或者偏差不是 −1,就以 exit 6 停下;
  • 磁盘低于 MIN_FREE_GB(默认 60G)停;有另一个 rpc-scan 在跑时等它。

  脚本发现新问题形状时以 exit 6 停止;开工前或写完后发现缺失高度时,以 exit 5 停止。

唯一被容忍的坏形状

  数据契约要求每个撮合组满足两条恒等式,不满足的照样入库,写进 invalid_groups。回填 09-04 到 09-11 时,第一次出现了一批 invalid 组,形状都是:

1
cash identity failed: N vs taker N

  驱动脚本记录的抽样是 130 个组,现金恒等式左侧都比 taker 少 1 个最小单位,即 1e-6 USDC,两个交易所都有,份额仍守恒。项目将它作为多腿撮合整数截断的已知形状处理;没有据此确认过某次合约升级。

  backfill-daily.sh 只容忍这一个问题形状,而且继续检查差额是否为 −1。其他形状或其他差额会停机,invalid 组仍留在账本中。

512MB 的字符串上限

  09-26,回填停在 92,298,180–92,301,779 这一段:

1
Cannot create a string longer than 0x1fffffe8 characters

  这里撞到的是 Node/V8 的字符串长度上限。undici 将响应 body 解码成字符串再解析,响应过大时,重试同样的区间不能解决问题。0x1fffffe8 是字符数上限,不能对任意编码直接等同于 512MB 响应体。

  脚本注释记录,这段回填往较早的日期走时,单日成交数从约 400 万涨到 540 万。09-27 把 STEP 默认值从 2000 改成 500,留下的 3,600 块缺口用 --step 250 重扫补上。缩小请求范围没有改变已提交块的续跑方式。

PG 重启杀掉了 rpc-scan

  另一次停机来自 Postgres。它在回填期间重启了一次(57P01),rpc-scan 随之死掉,留下 47,100 块的洞。

  账本写入方用 Postgres advisory lock 协调:扫描持 scan 锁,接近链头时还要持 live 锁;远离链头的历史扫描可以与 live 并行。锁会话使用 pg.Client,当时 src/store/locks.ts 没挂 error 监听。Postgres 断开会话后,未处理的 error 让进程退出;没有 supervisor 的长扫描就停在中途。

  修法分三处。锁会话丢失时报告一次,以退出码 75 退出(LOCK_LOST_EXIT);scan、live(含孤块回收)、archive-sync 每批写入之前,都在持锁会话上确认锁还在(assertHeld()),丢锁就绝不继续写;rpc-scan / archive-scan 退出前打印 RESUME_FROM,backfill-daily.sh 遇到 75 从续跑点重跑,最多 5 次。stats 丢锁时立刻退出,告警交给一个脱离的子进程(send-alarm)去发。

  补洞的顺序也有讲究。洞出现在 coverageStart 以上时,日驱动的连续性自查会直接停住,要先手工用小 step 重扫补洞、确认 missing=0,再恢复驱动。

  这件事还污染了一次测量。我当时在做一个 A/B 对比,两边差了 387,390 行,查了一会儿才想起来:中途补洞往被测窗口里写了 47,100 块,两次看的根本不是同一份数据。之后的 A/B 脚本在跑前跑后各数一次被测窗口的行数,断言相等。

按钱包读成交:fills_by_wallet

  回填完 30 天之后,接口开始慢。fills 表按块号排序,按钱包过滤时,钱包上的 bloom 索引帮不了活跃钱包:09-27 实测一个高频钱包在 30 天窗口里命中 13,963 个 granule 中的 13,758 个,实体页、标签、插件画像每次都要读整个窗口(6,000 万到 9,300 万行),撞上 10 秒的查询上限,返回 500。

  解法是建一张按钱包排序的副本 fills_by_wallet。麻烦在于它要和 fills 保持一致,而两张表没办法原子更新。

  前两版尝试同步墓碑,处理重放改钱包和旧行删除。复审发现,写入中途失败后重试可能无法重新推导墓碑;version 不在排序键中,后台合并也可能淘汰标记仍引用的已提交版本。这两处都需要改变副本的有效性判断。

  最后换了思路,副本只写不删,一行是否有效,看它的 version 是否等于提交标记的 version。writeBlocks 把一个块的事件行和它的 ingest_blocks 标记用同一个 version 写入,我在生产上核对过,2,790 万条 canonical fills 全部和自己标记的 version 相同。于是一条规则就覆盖了几种情况:重放改走了钱包的旧行(version 更旧)、标记没落地就失败的那次写入(version 更新)、被撤销或被重组的块(没有标记,或哈希不同),都自然不算。

1
2
3
4
5
SELECT e.* FROM fills_by_wallet AS e
INNER JOIN (SELECT chain, block, blockHash, version FROM ingest_blocks FINAL WHERE ...) AS b
  ON e.chain = b.chain AND e.block = b.block AND e.blockHash = b.blockHash AND e.version = b.version
WHERE e.wallet IN ({ws:Array(String)}) AND ...
LIMIT 1 BY e.chain, e.block, e.blockHash, e.txHash, e.logIndex

  version 必须放进排序键。放在键外,合并会只留最新版本,而最新的那个可能正是标记没落地的失败写入,标记指向的已提交行就被合并掉了。代价是重写过的块会在副本里留下旧版本。ensureFillsByWallet 在写入前核对已有表的引擎、排序键、分区键和列,不对就拒绝写。

  切换前核对了 11 段、1.78 亿行,提交记录报告与 fills_canonical 一致,然后把按钱包的 5 处成交查询改读副本(WALLET_FILLS)。一个高频钱包的 30 天窗口从 2.1 秒降到 0.18 秒。

  同一时期还发现 headBlock 每次用 max(block) 加 FINAL 扫全表取链头,推送每个 tick 调一次,一天 15.8 万次、读 5.2 TiB。改成按主键倒序取最新块再单行 FINAL,读取量降到约 1/100,并取最近 256 块里最新的那一块,头块在两次读取之间被重组删掉时回退一块,不返回空。拿不到链头时,订阅接口返回 503,不再从 block 0 开始推整段历史。


pmwallets 工程笔记(4/7)

上一篇:盈亏被算成 0:步骤顺序、0 金额转账和过宽的 partialHistory

下一篇:pmwallets 的成交推送:延迟锚点、LISTEN/NOTIFY 和一条钉住 xmin 的等锁语句

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

盈亏被算成 0:步骤顺序、0 金额转账和过宽的 partialHistory

LPSignal 的 Smart LP 排行:从 NFT 事件重建仓位与刷分修复