T17 · 索引器
ブロック再編成後、自分のデータはどう扱うか?
- 練習する能力
- BuilderOnchain Literacy
- 実践
- 任意のブロック高から再実行でき、結果が元と完全に一致する最小限のインデクサーを書く。
- AI Lab
- AI に再編成時のロールバック処理を書かせ、自分でフォークデータを作って削除漏れがないか検証する。
現実の問題
索引器は 2 週間動き、監視は正常です。停止、エラー、ブロック漏れがなく、遅延は 3 秒以内です。
朝、昨夜「500 を受け取った」と通知されたのに、ウォレットにないと苦情が届きます。
DB にはハッシュ、高さ、金額、時刻が揃った記録があります。ハッシュをエクスプローラーへ貼ると、
「取引が存在しません。」
その高さにはブロックがあるものの、取引がなく、内容全体が DB と違います。
索引器は停止も漏れもエラーもなく、後で存在しなくなった履歴を忠実に記録し、その履歴から通知しただけです。
T1 で予告し、T16 で block_hash を保存しました。今、本当に使います。
先端の履歴は書き換わりますが、DB は自分を書き換えません。両者の間に何を置くべきでしょうか?
思考実験
川の向こうの掲示板を望遠鏡で写します。最新の数件は、いつでも剥がされ別内容へ変わる一方、下の古い掲示は変わりません。
写し方は 3 つです。
第一は、何件目まで写したかだけ記録します。 1000 までと書き、次は 1001 からです。
996〜1000 が変わっても分からず、存在しない 5 件が本物に混ざり、区別する印がありません。
第二は、各内容の指紋と、その内容が主張する直前の指紋も記録します。 ノートも連鎖になります。1001 を見たら、その直前の指紋をノートの 1000 と比較します。
違えば、途中が変わりました。 誰にも聞かず、こちら岸で判断できます。
第三は、十分古くなった掲示だけ写します。 誤りは避けられますが、現実から遅れます。
第二にも問題があります。1001 が合わなくても、何件変わったかは不明です。 1000 だけか、996 以降すべてかもしれません。
逆順に照合するしかありません。1000、999、998 と比較し、初めて一致するところまで戻ります。そこが分岐点で、その後はすべて無効です。
これが本章の骨格です。残りは、誤らないコードへの落とし込みです。
あなたが決める
再編成の方針を選びます。
結果を観察する
比較します。
| 遅延 | 再編成検出が必要か | 復旧費用 | 複雑さ | |
|---|---|---|---|---|
| 深いブロックのみ | 高い | ほぼ不要 | なし | 最小 |
| 先端と全再処理 | 低い | 必要 | 非常に高い | 中 |
| 先端と分岐点への巻き戻し | 低い | 必要 | 低い | 高い |
| 二系統の確認状態 | 最小 | 必要 | 低い | 最大 |
重要なのは、第一以外のすべてが検出能力に依存することです。検出できなければ、巧妙な方針も無意味です。T16 のハッシュ列が必要な理由です。
第一の結論です。
再編成は異常でなく、チェーンの通常動作です。アラートではなく、通常のコード経路として扱います。
第二は、冒頭の苦情を説明します。
壊れるのは削除できるデータではなく、既に外へ出して取り戻せないものです。
通知、メール、支払いには delete がありません。DB 内は精密な削除と再計算で戻せますが、DB 外は戻せず、十分深くなるまで実行しないのが方法です。
異なる機構を混ぜると、冒頭の通知を生みます。
モデルを作る
メインループ
読みやすい擬似コードにします。
loop:
head = rpc.blockNumber()
cp = db.readCheckpoint(chainId) # 高さとブロックハッシュ
if not isStillOnChain(cp): # 1. 前進前に、まだ同じチェーンにいるか確認
rollbackTo(findForkPoint(cp)) # 一トランザクション
continue
from = cp.number + 1 # 2. 今回の範囲
to = min(cp.number + batchSize, head)
if from > to: sleep(pollInterval); continue
for n in from..to: # 3. 一ブロック一トランザクション
block = rpc.getBlockHeader(n)
if block.parentHash != lastHash: break # 取得中に先端が変化。次の回で再判定
logs = rpc.getLogs(n, n, filters)
db.transaction:
insertBlock(block)
insertLogs(logs) # 主キーによる冪等性
enqueueOutbox(logs, n) # 副作用を先に保存し、直接送らない
writeCheckpoint(n, block.hash) # データと同じトランザクション
lastHash = block.hash5 つの判断を、順に説明します。
再編成の検出:必須の 2 箇所
第一は、前進時です。 最初の対象ブロックの parentHash は、チェックポイントの last_block_hash と等しくなければなりません。違えば再編成です。
多くを捕捉できますが、もっと前で再編成が起き、既に通過した場合や、複数の RPC ノードのうち遅れたノードが返す場合、parentHash が合うことがあります。
第二は、定期的な再走査です。 blocks の最近 D 個のハッシュを、同高さのチェーンと再照合します。D は通常、観測した最大再編成深度の数倍です。
安価でも、第一で漏れ、最も調べにくい種類を捕捉できます。
分岐点:推測せず、逆にたどる
findForkPoint(cp):
n = cp.number
while n > cp.number - maxRollbackDepth:
onchain = rpc.getBlockHeader(n).hash
local = db.blockHash(chainId, n)
if local is null: return n # DB にないので境界
if onchain == local: return n # 最初の一致高さが分岐点
n = n - 1
raise DeepReorgError(n)3 点に注意します。
分岐点は最初の一致高さで、不一致高さではありません。 これが > と >= を決めます。一つずれると不正ブロックを残すか、正しいブロックまで消します。どちらもエラーなしでデータを誤らせます。
最大遡及深度は必須です。 ないと、別ネットワークや同期途中ノードへの接続などの RPC 異常で、創世まで戻って全削除します。実際にあり得る事故です。
最大深度の超過には、人の介入が必要です。 停止、通知し、独断で続けません。索引器で本当に人を起こすべき数少ない出来事です。
二分探索ではなく、線形に戻ります。深度は通常一桁で、数回の RPC が増えても、単純で誤りにくい方がよいのです。二分はここでは早すぎる最適化です。
巻き戻し:一トランザクション
T16 の表へ、副作用の待機表を加えます。
begin;
-- 0. 削除前に影響アドレスを集める。削除後では取得できない。
create temp table touched_holders on commit drop as
select distinct holder from (
select from_addr as holder from transfer_events
where chain_id = $1 and block_number > $fork
union
select to_addr as holder from transfer_events
where chain_id = $1 and block_number > $fork
) x;
-- 1. 事実とブロックを高さで削除
delete from transfer_events where chain_id = $1 and block_number > $fork;
delete from blocks where chain_id = $1 and block_number > $fork;
-- 2. 未送信の副作用も削除
delete from outbox
where chain_id = $1 and block_number > $fork and delivered_at is null;
-- 3. 影響 holder の派生残高を消し、残った事実から再計算
delete from token_balances b
where b.chain_id = $1
and exists (select 1 from touched_holders t where t.holder = b.holder);
insert into token_balances (chain_id, contract, holder, balance, updated_at_block)
select ... from transfer_events
where chain_id = $1 and to_addr in (select holder from touched_holders)
or chain_id = $1 and from_addr in (select holder from touched_holders)
group by chain_id, contract, holder;
-- 4. チェックポイントを分岐点へ戻す
update indexer_checkpoints
set last_block_number = $fork, last_block_hash = $forkHash
where chain_id = $1;
commit;全 5 工程を同じトランザクションにします。 途中停止では、エラーのない矛盾した DB ができてしまいます。
第 0 工程は逆にしやすいものです。影響アドレスは削除前に集めます。後なら対象が空になり、派生は再編成前の誤値で止まります。
チェックポイントの 2 種類の誤り
最も覚えるべきルールです。
誤り 1:チェックポイントを先にコミットし、後でデータを書く
途中停止 → 処理済み表示だがデータなし
→ 永久に欠落し、検出もされない
誤り 2:データを書いてから別トランザクションで進捗をコミット
途中停止 → データはあるが進捗未更新
→ 再起動で再処理 → 冪等書き込みが重複を防ぐ → 無害
正解:データと進捗を同じトランザクションでコミット同じトランザクションにできなければ、チェックポイントを後にします。漏らすより、やり直します。
停止を正確性の問題から性能の問題へ変えるルールです。前提は T16 の on conflict do nothing です。冪等性は再開の最適化でなく、基礎です。
過去の埋め戻しと最新追跡:同じコード、異なる駆動
同じ処理で、範囲を誰が決めるかだけが違います。最新はチェックポイントの次から直列処理し、毎回再編成を確認します。過去は待機区間表から範囲を取り、安定済みなので再編成を気にせず並列化できます。
並列の罠は、進捗を一数値にできないことです。
1〜100、101〜200、201〜300、301〜400、401〜500 の 5 分割で、1、2、4 が終わり、3 が失敗したとします。最大完了高だけなら 400 となり、途中の 100 ブロックの穴が見えなくなり、発見されません。
区間ごとに状態と試行回数を持ちます。
create table backfill_ranges (
chain_id integer not null,
from_block bigint not null,
to_block bigint not null,
status text not null, -- pending / running / done / failed
attempts integer not null default 0,
primary key (chain_id, from_block)
);完了とは、done 以外の行がないことです。一 SQL で答えられ、最大高より確実です。
副作用:戻せない操作を安全高の後へ
冒頭の原因です。
不可逆な操作は、ブロック処理中に直接実行しません。二工程へ分けます。
create table outbox (
id bigserial primary key,
chain_id integer not null,
block_number bigint not null, -- 安全な深さを判定する所属高さ
event_key text not null, -- 配送側が重複除去するチェーン座標
payload jsonb not null,
delivered_at timestamptz,
unique (chain_id, event_key)
);第一に、送る内容をデータと同じトランザクションで保存し、まだ送りません。失敗や再編成では一緒に消えます。
第二に、独立した配送器が、次の条件で送ります。
select * from outbox
where chain_id = $1
and delivered_at is null
and block_number <= $safeHeight -- 十分深いものだけ
order by block_number, id
limit 500;safeHeight が確認深度の効く場所です。十分に深くなるまで、イベントは DB の外へ出ません。
T1 の不存在送金の通知は、再編成時にはまだ outbox にあり、delete で消えるため、防げます。
確認深度は全体の定数ではなく、業務のリスク別設定です。
| 用途 | 深度の考え方 |
|---|---|
| 処理中の表示 | 0、即時 |
| 一覧、統計、グラフ | 浅め、多少の変動を許容 |
| 通知、メール | 中程度、送ると戻せない |
| 報酬、記帳、決済 | 深め、照合も必要 |
同じチェーンでも業務ごとに深度が違うのは正常です。 コードへ散らさず設定表にします。
合格基準:自動実行できる条件
一つのアサーションにできます。
任意の高さから再処理しても、最終結果は元とバイト単位で一致します。
T16 のチェックサムを記録し、ある高さ以降を削除し、進捗を戻して再処理し、再計算します。二つの値は同じでなければなりません。
曖昧な品質を、二値の自動テストへ変えます。達成できない索引器には、未発見の再編成処理の誤りがあります。
何と呼ぶか
高さ順に進み、区間ごとのブロックとログを取得するループです。
本体に見えても、最も簡単な部分です。難しさは、進捗、再編成、冪等性、副作用という周囲にあります。
ログを構造化して保存し、T16 の事実表を作ります。
チェーン座標の主キーが正確性を担い、DB が重複を拒否します。
先端の複数ブロックが別の列へ置き換わり、処理済みブロックがチェーンから外れることです。
故障でなく通常の出来事です。 ハッシュ比較で検出し、分岐点へ戻します。高さだけでは検出できません。
逆順に比べたとき、ローカルとチェーンが初めて一致する高さです。その後のデータは無効です。
最初の不一致ではありません。> と >= の境界を決め、一つ違えば無効データを残すか正常データを消し、どちらもエラーが出ません。
処理位置の高さとハッシュを持つカーソルです。
データと同じトランザクションで、無理ならデータの後にコミットします。 前なら欠落、後なら再処理です。
最新追跡と同じ処理を、異なる範囲指定で過去へ実行します。
並列では、一数値でなく区間表が必要です。最大完了高では途中の穴が静かに消えます。
戻せない副作用を DB に先に保存し、別プロセスで後から送る方式です。
データと同じトランザクションで保存して、データなしの通知を防ぎ、再編成と共に削除可能にして、未送信を消します。深さ条件が確認深度を実際に適用します。
実践
すべてテストネットの読み取りです。 署名、送信、支払いはしません。
合格基準は、すべて自動化できる 3 点です。
条件 1:同区間を何回処理してもチェックサム不変
条件 2:再編成を模擬して巻き戻し再処理すると、元のチェックサムへ戻る
条件 3:無作為に 20 回強制終了しても、再起動後のチェックサムが一致T16 の 3 表に、2 表を加えます。
transfer_events、blocks、indexer_checkpoints を移し、outbox、backfill_ranges を加えます。
各行について、取引、高さ、現チェーンへの所属の 3 問に答えられるか確認します。
まず再編成なしのループを書きます。
擬似コードの isStillOnChain 部分を一時省略します。
次を正しく実装します。
- 一ブロック一トランザクション
- イベント挿入は on conflict do nothing
- 進捗とデータを同じトランザクションでコミット
- blocks へヘッダーを記録し、parent_hash も保存500 ブロックを処理し、T16 の照会でチェックサムを記録します。
条件 1:任意の高さから再処理します。
-- 高さ N 以降を消し、進捗を戻す
delete from transfer_events where chain_id = $1 and block_number > $N;
delete from blocks where chain_id = $1 and block_number > $N;
update indexer_checkpoints set last_block_number = $N,
last_block_hash = (select block_hash from blocks
where chain_id = $1 and block_number = $N)
where chain_id = $1;再起動して元の高さまで処理し、再計算します。
完全一致が必須です。 違えば止まります。後のすべての前提だからです。異なる N を 3 つ選んで試します。
再編成検出と分岐点探索を実装します。
isStillOnChain と findForkPoint を追加し、最大深度と、超過時の停止、通知も入れます。
境界に注意します。最初の一致高さを返し、削除は block_number > fork です。次で試します。
条件 2:待たずに再編成を模擬します。
-- ハッシュを変え、このブロックが現チェーンと違う状態を模擬
update blocks
set block_hash = decode('00' || encode(block_hash, 'hex'), 'hex')
where chain_id = $1 and block_number = $N - 3;その後の高さへ偽の送金イベントも入れます。
再起動し、不連続を検出し、N - 4 の分岐点へ戻り、以降を消して再索引するか確認します。
チェックサムは第 2 工程の値と一致し、偽イベントがすべて消える必要があります。
境界実験として、> を故意に >= へ変え、差を見ます。一つのずれの結果を自分で見る方が、規則を暗記するより有用です。
条件 3:無作為な時点で強制終了し、再起動を 20 回繰り返します。 毎回 3 点を確認します。
- 対照群とチェックサムが一致するか?
- 進捗の高さと blocks の最大高が一致するか?
- 進捗は N なのに、N のデータがない状態はないか?最後の状態はコミット順序の誤りです。 再起動、リリース、OOM、マシン停止という日常の運用を模擬するため、本当の問題が出やすい工程です。
outbox を追加し、副作用が早く出ないか確認します。
通知をイベントと同じトランザクションで保存し、配送器は安全高以下だけを送り、delivered_at を更新します。
条件 2 を再実行し、巻き戻した高さに delivered_at が非空の行がないか調べます。ゼロ件が必須です。 これで冒頭の苦情を防ぎます。
一週間先端を追い、実際の再編成を記録します。
確認深度 0 で、生成の速いテストネットを追い、時刻、分岐高さ、深度を記録します。
一週間後に分布を見ます。自分の業務に必要な確認深度を直接判断するデータです。記事の推奨値を写すより確実です。
AI Lab
3 段階で、第三が本当の試験です。
第 1 段階:
事実表(主キー chain_id + block_number + log_index)、
ブロック表(block_hash と parent_hash)、進捗表(高さとハッシュ)があります。
分岐点探索を含む、完全な再編成検出と巻き戻しの擬似コード、SQL を書いてください。
第 2 段階:
DB に 1000〜1010 があり、1006〜1010 が別の 5 ブロックへ変わり、1005 以前は同じです。
1. 1011 処理時のどこで異常を検出するか?
2. findForkPoint はどの順に比較し、何を返すか?
3. 削除条件と対象高さは?
4. > を >= に変えると、何を余分に消し、何が起きるか?
第 3 段階:
再編成時点で、1006〜1010 の通知、メール、ポイント配布を既に行いました。
これらは削除できません。再発を防ぐ方式を設計し、なお失敗する条件も説明してください。第一は頻出題なので答えますが、多く書かれた題だからです。ハッシュか高さ、最大深度、トランザクション境界を注視します。
第二は算術でも、数字を段階的に推論する誤りは想像より多く、分岐点の一つのずれが典型です。findForkPoint が 1006 を返して > 1006 で消すと、無効な 1006 が残ります。レビューで見つけにくく、永続する誤りです。
第三は、技術より境界を問います。戻せるものと戻せないものを分け、outbox と深度を示し、なお失敗する条件も自ら説明するのが良い答えです。12 確認で 30 深度の再編成が起きたら、送信済み通知をどうするかと追加で聞きます。技術ではなく業務の答えしかありません。その認識が、コードを書くこととシステムへ責任を持つことを分けます。
最後に、Lab 第 5 工程の分岐を作ってコードを実行し、チェックサムを比較します。T16 と同様、レビューではもっともらしい誤りも、データで初めて現れます。
AI の回答後、自分で必ず検証すること
- 高さのみでなくハッシュで検出する。高さだけなら検出できず、書き直す
- 固定深度の仮定でなく、逆順にハッシュを比較して分岐点を探す
- 最大遡及深度を設け、超過したら削除を続けず停止、通知する
- 削除の > と >= を、自作データのチェックサムで比較した
- 派生も戻し、再計算用の影響アドレスは削除前に集める
- 進捗更新と削除が同じトランザクションで、順序も正しい
- 通知やメールなど、戻せない副作用を扱っている
- 遅れた RPC や複数ノードの不一致も考慮する
- 説明だけでなく、自作分岐データで実行し、チェックサムを比較した
実例
冒頭と、T1 で予告した場面です。
生成直後に通知し、再編成後にはその取引が入らず、不存在の通知だけ残りました。
一通知より、以降ユーザーが通知を信じなくなることが問題です。outbox と安全高判定で防げます。
1000 までという進捗だけ記録し、parentHash の確認なしで、再編成後も 1001 から続けました。
5 ブロックが正データに混ざり、どの照会も印を付けません。3 か月後の照合で不一致へ気付いても、どの行が不正かの情報がありません。
T16 の実例の続きです。ハッシュは安価な保険です。
トランザクションを小さくするため進捗を先の独立トランザクションへ分け、リリース再起動が間に入りました。進捗だけ進み、一ブロックの全イベントが永久に欠けました。
通知もログもなく、照会でも分かりません。 システムは処理済みと思います。漏らすよりやり直すことが教訓です。
5 分割の一つが RPC 制限で失敗しても、最大完了高だけの監視は正常と表示しました。
100 ブロックが欠け、数週間後の履歴の苦情で発見しました。
並列進捗は一数値ではありません。 done 以外の区間がないことを、一 SQL で確認します。
変数を一つ変える
超えたブロックは変わらず、最大深度と安全高に理論上の根拠ができ、単純になります。
ただし先端は再編成するため、検出と巻き戻しは必要です。違いは十分な深さを推測しなくてよいことです。定義はチェーンごとに違い、定数ではなく設定表の一行にします。
進捗の高さが新ノードにまだなく、取得不能をハッシュ不一致と誤解すると、正しい 10 ブロックを削除します。
先端が進捗より低い状態を分け、戻さず待ちます。自分が先行することと、間違うことは別です。
コードは停止、通知し、削除を続けません。深い再編成と、接続ネットワークの誤りを区別できないためです。
業務では、通知済みや報酬済みをどう扱うか答えます。事故後ではなく、公開前に答えを用意します。
速くても、要求中の再編成で、二つの分岐をまたぐログが返るかもしれません。各ログのハッシュをブロック表と照合し、違えば一括破棄して再取得します。
一括要求の内部整合性を信用しません。 範囲が大きいほど再編成をまたぎやすく、先端付近は小さく、遠くは大きくします。
持ち帰る問い
何を解決しますか?書き換わる履歴を、自分で書き換わらない DB へ安全に保存します。難しいのは取得ではありません。取得ループは半日で書けます。再編成、進捗、冪等性は、事故時にだけ問題が現れます。
誰がリスクを負いますか?ユーザーが誤残高、通知、報酬を受け、運営は何か月も気付かないかもしれません。静かにチェーンと分かれるため、見た目でなく自動アサーションのチェックサム一致を基準にします。
AI が間違えると誰が損失を負いますか?説明は正しくても、符号一つの違いで無効ブロックが永久に残ります。レビューだけではなく、自作データで実行して初めて発見します。 責任はコードを動かす人です。
章末チェック
高さはブロックを一意に定めず、再編成後に内容が変わるためです。
当時のブロックが今もあるか答えられず、平然と続けて無効データを残し、照会でも見えません。
進捗に高さとハッシュを持ち、ブロック表に parent_hash も保存して、連続性を独立検証します。
最初の一致高さです。 そこは正しく、その後だけが無効なので、厳密な大なりで消します。
以上なら正しい一ブロックを余分に消し、不一致を返して大なりなら無効を残します。逆方向の誤りでも、どちらも静かです。エラーなしでチェーンとずれます。
分岐データを作り、両方で実行してチェックサムを比較します。
停止は任意の二文の間に起き、順序で結果が異なるためです。
進捗が先なら、処理済みなのにデータなしとなり、永久に欠落します。進捗が後なら、再処理を冪等性が防ぎ、余分な一回だけで正しくなります。
同じトランザクションにし、無理なら進捗を後にします。漏らすより再処理します。 主キーの冪等性は、再開の基礎です。
通知、メール、支払いに delete はなく、DB は消せても、送ったものは戻せません。
データと同じトランザクションで保存してデータなしの通知を防ぎ、再編成で未送信を消せるようにします。
配送器は十分深い行だけ送り、表示は 0、報酬は深くなど、業務別の確認深度を適用します。
唯一の正解はありません。次を確認します。
- 何回再処理してもチェックサム不変を、SQL で確認。
- 任意高さから削除再処理し、元へ戻るか、3 高さで確認。
- ハッシュを変える模擬再編成後、巻き戻しでチェックサムが戻る。
- 無作為の強制終了 20 回後、データと進捗が矛盾しない。
- 巻き戻した高さに、配送済み outbox がない。
- 並列完了は最大高でなく、非 done 区間がないこと。
- 最大深度超過で、削除継続ではなく停止、通知する。
- 先端が進捗より低ければ、戻さず待つ。
最初の 5 点は自動化できます。本章が渡したいのはこれです。索引器の正しさを、曖昧さから、成功と失敗が分かるアサーションへ変えます。
要点
インデクサーの難所は取得ではない。再編成、途中からの再開、冪等性だ。