Crypto OS
Technical Crypto OS第3段階 · フルスタックのオンチェーンアプリケーション

T18 · リアルタイムシステム

チェーンの変化を、どうリアルタイムでユーザーへ届けるか?

この章を 3D で体験する →
練習する能力
Builder
実践
購読 API を実装し、30 秒切断してから再接続して、間のイベントをすべて補完できるか確認する。
AI Lab
AI に再接続プロトコルを設計させ、弱いネットワークでのパケット損失を自分で測る。

現実の問題

T17 で信頼できる索引器ができました。長時間接続を追加し、ブロック処理ごとにイベントを送り出すと、ローカルでは数秒で届き、快適です。

公開 2 週間後、再現しにくい似た苦情が出ます。

  • 「活動一覧に数件足りないが、更新すると現れる。」
  • 「処理中のままだが、実際には成功済み。」
  • 「画面の残高がエクスプローラーと違う。」

サーバーログは正常です。すべて送信しました。 送信関数は成功し、例外も滞留通知もありません。

共通点は、モバイル回線で短時間切断したことでした。

設計は送ったら忘れる方式でした。その瞬間接続中なら届き、不在なら永久に消えます。ネットワークでなく、設計で失っています。不在への準備が何もないからです。

問いは、30 秒切断して戻ったユーザーへ、その間の出来事をどこから補うかです。

思考実験

3 つの配送方法を考えます。

第一はラジオ放送です。 聞いている人には届き、不在なら番組は消え、何を逃したか誰も知りません。

サーバーが聴衆について何も記憶せず、低費用です。到達は運に依存します。

第二は、番号付き掲示板です。 連番を付けて壁へ残し、戻った人が「812 まで見た」と言えば、813 以降を読めます。

切断は復旧不能の事故から、補完できる追い付きへ変わります。ただし壁を保存し、有限の容量なので古いメッセージはいずれ消します。

第三は、変化したから見に行くよう知らせるだけです。 内容でなく信号を送り、受信者が最新状態を照会します。

一信号の喪失にはほぼ影響がないという利点があります。次の信号でまとめて更新し、冪等で喪失に強い方式です。ただし途中の出来事ではなく、現在の状態しか答えられません。

共通する基準は次です。

リアルタイム配信は、再生可能か、正しい状態へ収束可能にします。どちらもなければネットワークへの賭けです。

第一はどちらもなく、必ず失います。冒頭の苦情は、その三つの表れです。

あなたが決める

チェーンの変化で画面を更新する方法を選びます。

結果を観察する

異なる指標で比較します。

遅延切断時の喪失サーバー状態費用途中の出来事を答えるか
定期取得高い状態は回復、過程は失うなしいいえ
送って忘れる最小永久に失うなし可能だが不確実
番号と補完最小失わない高い、窓を保存はい
変化信号のみ低い状態は回復、過程は失う非常に低いいいえ

遅延と喪失は無関係です。本章の中心は、

リアルタイムは遅延の指標、失わないことは正確性の指標で、別の機構が保証します。

多くは前者だけ実装し、安定回線の開発ではよく見えます。問題はユーザー側で、まれに数件欠ける、調査困難な形で現れます。

前章とつながる第二の結論です。

先端のイベントは取り消され得ますが、配信済みイベントは自分では消えません。

取り消しメッセージを用意するか、T17 の outbox 条件で十分深いものだけ送ります。失わないことと、偽でないことは別の要件で、両方が必要です。

モデルを作る

三要素

イベントを失わないシステムの要素です。

  1. 単調カーソル
  2. 再生可能な窓
  3. 再接続時の補完プロトコル
一つ欠ければ切断で失います。三つあれば、イベント喪失を設計上防げます。

カーソルの候補:三つ中、二つは誤り

タイムスタンプは誤りです。 同ミリ秒に複数あり、時計変更や複数インスタンスのずれで単調にならず、重複か欠落が出ます。

DB の自動採番も、多くの場合誤りです。 採番とコミットの順序は違います。A が 102、B が 103 を取り、B が先にコミットすると、読み手が 103 へ進んだ後で A がコミットします。102 は永久に読まれません。

エラーのない穴です。使うなら、十分古い行だけ読むか、単一書き手が直列割当する必要があります。

チェーン座標が適切です。 (chain_id, block_number, log_index) は単調で一意、インスタンス間で一致し、再起動後も一致します。ユーザーもチェーンで照合できます。

比較可能な文字列か大整数へ符号化します。

cursor = chainId : blockNumber : logIndex
      例:  1 : 00000000020134 : 0007

メッセージプロトコル:必須の種類

ライブラリから独立して記述します。

クライアント → サーバー   subscribe  { topics, cursor }   既読位置付き購読
サーバー → クライアント   snapshot   { cursor, state }    任意の現在状態
サーバー → クライアント   event      { cursor, type, payload }
サーバー → クライアント   revoke     { fromCursor }       この位置以降を無効化
サーバー → クライアント   reset      { reason }           古いカーソル。再購読を要求
双方向                    ping / pong                      生存確認

最後の三種類が漏れやすいものです。

reset は必須です。 有限窓の外まで離れたクライアントに、reset なしで黙って何も補わないと最新だと誤認します。正常に見えるため、切断より悪い状態です。

revoke は再編成の出口です。 未確認を送るなら、直前のイベントの無効化を通知します。実装しないなら、十分深いものだけ送り、深度へ任せます。

ハートビートは双方向です。 一方だけ送ると、片側しか相手の停止を判定できません。両側にタイムアウト判定が必要です。なければ半開接続が残ります。

スナップショットと増分の境界

現在を取得してから購読する順序は誤りです。

誤った順序:
  t0  スナップショット取得(cursor=100 まで)
  t1  ← cursor=101 が発生するが、誰も監視していない
  t2  購読を開始し、102 以降を受け取る

  結果:101 が兆候もなく永久に欠ける

購読、バッファ、スナップショット、解放の順です。

1. 接続して購読し、サーバーが接続のキューへイベントを入れ始める
2. まだ配信せず、クライアントかサーバーでバッファする
3. スナップショットと、その cursor を取得する
4. バッファの cursor がスナップショットより大きいものだけ順に送り、古いものを捨てる
5. 通常配信へ移る

逆の順序が、再接続後の欠落の頻出原因です。ちょうど境界の瞬間にイベントが必要なので、ローカルでは再現しにくいものです。

バックプレッシャー:待つのでなく縮退する

弱い回線、背景タブ、遅い実装で、読み手が遅い場合です。

方針結果
読み終わるまでブロック一クライアントが全体を止める
無限バッファピーク時にメモリが増え、プロセスが終了
有界キュー、満杯で縮退対象へ reset して再取得させ、他へ影響しない

第三が正解です。

バックプレッシャーの出口は、待機ではなく縮退です。

接続ごとに例えば 1000 件の有界キューを持ち、満杯なら、その接続の全待機を消して reset します。購読、スナップショット、補完を再実行させます。

古い数件だけ消してはいけません。見えない穴を作ります。すべて捨て、やり直しが必要と明示します。

再接続:バックオフにジッターを付ける

即時再試行は、障害サーバーをさらに圧迫します。指数バックオフを使います。

n 回目の待機 = min(基本間隔 × 2 の n 乗, 上限) × 乱数係数
係数は 0.5〜1.5

乱数は必須です。なければ再起動後に全クライアントが同秒に戻り、次の再起動でも同じことを繰り返す、周期的な自己攻撃になります。

配送保証:サーバーの正確に一度を追わない

ネットワークで正確に一度は実現できず、次を組み合わせます。

サーバー:少なくとも一度の配送。補完で数件再送することがある
クライアント:cursor で除去。既読以下なら捨てる
組み合わせ:効果として正確に一度

T15 の冪等性、T16 の主キー、T17 の再開と、同じ手法の四度目の登場です。一度だけ行うのでなく、複数回でも同じ結果にします。

ファンアウトと複数インスタンス

接続別キューと、トピックから接続への索引を用意し、イベントで検索、配送します。単一なら単純です。

複数なら中間のメッセージ基盤を使えますが、T16 の事実は一つという規則を守ります。基盤は伝送で、事実源ではありません。補完は DB をカーソルで照会します。

基盤の保存方針、分割順序、消費位置は別の故障可能な状態です。速さは基盤へ、正確性は DB へ任せます。

何と呼ぶか

WebSocket全二重の長時間接続

ブラウザーとサーバーが両方自発的に送信できる接続です。

解決するのは通路だけです。切断中の喪失はプロトコルの仕事で、長時間接続だけで信頼できる配信にはなりません。

Subscription購読

クライアントが関心のあるトピックを宣言し、サーバーが対象を選びます。

カーソルを含められる必要があります。トピックだけなら、再接続ごとに今から始め、切断中を失います。

Cursorカーソル

受信位置を示す単調増加の位置です。

時計は厳密に単調でなく、自動採番はコミット順で穴ができます。チェーン座標は自然な最適解です。単調、一意、再起動後も有効で、照合可能です。

Replay再生

カーソル付きで戻ったクライアントへ、後続イベントを順に補います。

再生窓の保存が前提です。有限なので、古すぎるという通知が必要です。なければ正常に見える、永続的な欠落状態になります。

Backpressureバックプレッシャー

消費が生産より遅く、圧力が上流へ伝わることです。

待たず縮退します。 有界キューを消して再取得させます。ブロックは全体を止め、無限バッファはピーク時にプロセスを止めます。

Heartbeatハートビート

相手の不在を検出する、定期的な相互メッセージです。

双方向が必須です。一方だけでは、消えた相手へ書き続ける半開接続が静かに蓄積し、資源を使い切ります。

実践

実践購読 API を作り、30 秒切断後に全イベントが補完されるか検証する任意の言語の WebSocket サービス + PostgreSQL + T17 の索引器0 円。ローカルサービスとテストネットの読み取りだけで、取引も秘密鍵も不要

すべてローカルとテストネットで読み取りだけです。 署名、送信、支払いはしません。

自動化できる条件は次です。

30 秒切断して再接続した受信列が、
常時接続の対照クライアントと、数量、順序、内容すべてで一致する。

T17 の outbox に接続し、チェーン座標を使います。

新しい事実源を作らず、outbox の行を、十分深いという条件で送ります。

(chain_id, block_number, log_index) を比較可能な文字列へ符号化します。正しく選べば、補完は普通の範囲照会です。

メッセージを実装し、reset を省きません。

subscribe、snapshot、event、reset、双方向ハートビートを作ります。深いイベントのみなので、revoke は後でも構いません。

補完照会です。

select * from outbox
 where chain_id = $1
   and (block_number, log_index) > ($2, $3)   -- クライアントのカーソル
   and block_number <= $safeHeight
 order by block_number, log_index
 limit 1000;

limit 超過は一気に送らず、区切り、各回で接続の生存を確認します。

境界の順序を正しくします。

購読、バッファ、スナップショット、カーソル別解放の順です。

スナップショット行の前後へ各 1 秒の遅延を足し、その間にイベントを発生させます。クライアントで見える必要があります。 なければ順序が逆で、本番の偶発欠落を生みます。

30 秒切断を試します。

同じトピックの二クライアントを開き、一方を常時接続、他方を 30 秒切ってカーソル付きで戻します。

受信列を比較します。

- 数量は同じか?
- 順序は同じか?
- 各内容は同じか?
- 重複が届いた場合、除去が機能したか?

スクリプトへまとめると、システムの回帰テストになります。

遅いクライアントで圧力を作ります。

一件ごとに 1 秒 sleep する読み手へ、1 万件送ります。

先に無限バッファでメモリを見ます。増え続ける様子は、Lab で最も学びのある 3 分です。

次に 1000 件などの有界キューへ変え、満杯なら消して reset します。メモリが安定し、通常のクライアントへ影響しないか確認します。

弱い回線で試します。

OS のネットワーク模擬機能を使い、ヘルプで引数を確認し、遅延、損失、帯域制限を加えます。

各条件で合格スクリプトを実行します。

- 500 ミリ秒の遅延
- 10% のパケット損失
- 非常に低い帯域でバックプレッシャーを発生

損失では短すぎる生存期限が現れやすいものです。実際は生きた接続が、何度も停止扱いされます。

再編成を発生させ、偽イベントが漏れないか試します。

T17 の模擬で巻き戻します。

対象高さのイベントは一件も受け取っていないべきです。 安全高に達する前に outbox から消えるためです。

逆も見るなら、安全深度を 0 にして、不存在の送金が配信される様子を見ます。T1 の問題そのものです。

200 接続を同時に開き、サーバーを再起動します。

同秒に戻るなら、バックオフにジッターがありません。

加えて再試行し、時間が分散するか見ます。200 でも差は見え、本番では数万接続です。

AI Lab

AI LabAI に再接続プロトコルを設計させ、弱い回線で実際の喪失を試すLevel 2 · AI Copilot

3 段階で、第二、第三が本当の試験です。

第 1 段階:
切断再接続でイベントを失わない、チェーンの購読プロトコルを設計してください。
全メッセージ型、フィールド、両側の状態機械を示してください。

第 2 段階:
各場面で、どの部分が対応するか説明してください。
1. 30 秒切断し、12 イベントが発生。
2. 3 時間切断し、再生窓は 1 時間。
3. 読み取りが配信の十分の一の速度で、10 分継続。
4. 再起動で 5 万クライアントが同時切断。
5. スナップショット取得と購読の間にイベントが発生。
6. 配信済み 3 件が、再編成で無効になる。

第 3 段階:
未対応の場面はどれですか?最小変更と代償を説明してください。

第一はよく答えます。カーソル購読が頻出題だからです。ただし時計を提案しがちで、複数台、時計変更、同ミリ秒で誤ります。

第二では、2、3、5、6 が頻出の失敗です。reset 漏れ、無限バッファ、逆の順序、再編成の省略です。4 はジッターの有無を明らかにします。

第三は知識でなく、正直さを問います。良い答えは未対応を認め修正し、悪い答えはすべて対応済みと主張します。案を出させるより、自分の案の穴を指摘させる方が有用です。T1 の AI Lab と同じ規則です。

最後は弱い回線で自分が実測します。失敗は現実の条件で現れ、10% 損失での挙動をモデルはもっともらしく推測するだけです。

AI の回答後、自分で必ず検証すること

  • 時計や自動採番でなくチェーン座標を使う理由を追問した
  • 窓の期限切れを知らせるメッセージがある。なければ静かに欠落が続く
  • 生存確認が双方向で、両側がタイムアウトを判定する
  • 再接続にジッターがあり、再起動後に全員が同秒へ集中しない
  • 購読がスナップショットより先で、境界にイベントを作って検証した
  • 有界破棄と reset を使い、ブロックや無限バッファは修正した
  • 再編成の取り消しか、深いイベントのみという方針が明確である
  • ライブラリ API、フレーム型、終了コードを文書で照合した
  • 遅延、損失、帯域制限の三条件で、常時接続群と受信列を比較した

実例

再接続後に数件欠け、再現できない

スナップショットの後に購読し、その間を誰も受け取りませんでした。

低確率でもなくならないのが特徴です。活発なユーザーには毎日一、二度、開発では数十ミリ秒の偶然が必要で再現できません。

購読とバッファを先にし、取得後にカーソルで解放します。

一つの遅いクライアントが全体を停止した

弱い回線の送信バッファが満杯で、ブロック書き込みが返りません。

共有の配送ループなら、全員への配信が止まります。

無限バッファなら、ピークでメモリを使い切り、最も止まれない時に停止します。

有界キューと縮退が正解です。

半開接続が数万蓄積した

サーバーだけがハートビートを送り、応答を確認しませんでした。

モバイル切り替えや NAT の期限切れでクライアントが消えても、サーバーは生きていると思って書き続け、メモリとトピック索引を消費します。

双方向と、両側のタイムアウトが必要です。 5 分で書けても、省けばいずれ事故になります。

未確認イベントを送り、再編成後に取り消さなかった

ブロック生成直後に送り、再編成で取引が消えても表示が残りました。

T17 と同じ問題の、通知と画面という二つの出口です。

十分深いものだけ送るか、取り消しを実装します。 何もしなければ不存在の履歴を表示します。

変数を一つ変える

もしモバイルアプリが背景で停止される

接続が静かに切れ、復帰後も接続済みと思い、死んだ接続へ書き続けます。

前景へ戻ることを再接続のきっかけにし、能動的に再接続してカーソルで補います。TCP の期限を待ちません。

停止は数時間続き得るため、窓切れの reset と再取得は、例外でなく主経路です。

もし同じユーザーが 5 タブを開く

5 接続、5 購読、5 倍の配信があり、カーソルも別々です。

共有背景コンテキストで一接続へまとめるか、費用を受け入れ、ユーザー別接続数を制限します。数十タブで不釣り合いな資源を使わせません。

T15 の二系統の制限の具体例です。

もしポーリングへ変え、遅延 1 秒を要求する

一万人なら毎秒一万照会で、大半は変化なしです。

キャッシュと、変化まで要求を待たせる仕組みを作れば、別形式で長時間接続を再実装したことになります。

ポーリング費用は低遅延要求で急増し、長時間接続費用は接続数に比例します。 新しさでなく遅延と規模で選びます。

もし購読が千から百万へ増える

接続メモリ、トピック索引、イベントごとの配信が同時に重くなります。

内容を軽くし、信号と取得でキャッシュ可能にし、トピック別に分割し、最後に複雑な配送層を考えます。

配送層は速さ、DB は正確性です。 補完は常に DB のカーソル照会にします。それが百万接続でも保つ経路です。

持ち帰る問い

1
どんな問題を解決するか?

何を解決しますか?不安定な回線でも、画面をチェーンと一致させます。速さと、失わない正確性は別の目標です。前者だけなら、公開時によく見えてもユーザーが問題を発見します。

5
誰が支払っているか?

誰が支払いますか?接続数と配信量に応じた費用で、メモリ、書き込み、再生窓は制御しにくい項目です。信号のみと深いイベントのみという設計は、正確性に加えて費用のためでもあります。

9
誰がリスクを負うか?

誰がリスクを負いますか?ユーザーは不完全な画面を見て、それを知れません。代償は数件でなく、画面への信頼を失うことです。毎回エクスプローラーで確認するなら、製品は不要な中間層になります。

章末チェック

要点

リアルタイムシステムは再実行できなければならない。ユーザーが切断後に戻っても、その間のイベントを失ってはいけない。

この章の実践課題を終えたらチェックしてください進捗をすべて見る

このページの内容