再送は当たり前だ
一言でいうと
チャネルは、タイムアウトになると同じGUIDで再送します。勘定系が冪等でないなら、重複を防ぐのは中継層の役目で、そのための道具は、GUIDを主キーにした状態元帳です。元帳は「処理した」だけでなく、「送ったがわからない」と「送れなかった」を区別して書く必要があり、再処理はその区別の上でだけ安全です。
なぜ必要なのか
モジュール4の中継は、受け取ったとおりに呼び出します。ネットワークが正常なら問題ありませんが、チャネルとハブの間でレスポンス1つが遅れて届くと、チャネルは失敗と思って再送し、ハブは2回目のリクエストも勘定系に渡します。このコースの勘定系フィクスチャは、わざと冪等でなく作ってあります。同じ振込を2回受けたら、2回引き落とします。現実の元帳システムも、「同じリクエストか」を自分で判断できないことが多く、判断できる場合でも、その根拠(取引固有番号)を誰かが最後まで運ばなければなりません。
メッセージングの配信保証を思い浮かべると、重複は例外ではなく既定値です。リクエストを確実に届けるには、応答がないときに再送しなければならず(少なくとも1回)、再送した瞬間に受信側は同じものを2回受け取りうるのです。Enterprise Integration Patternsは、この問題を受ける側で解く方法をIdempotent Receiverとして整理しています。同じメッセージを何回受け取っても、結果が1回受け取ったときと同じになるようにするのです。このモジュールでは、中継層が勘定系の前でその役割を担います。
どう動くのか
元帳の1行 = GUID 1つ。guidを主キーにすれば、「同じGUIDが2行ある」という状態そのものが不可能になります。元帳には、状態・応答コード・リクエスト原文・レスポンス原文・試行回数・更新時刻を置きます。リクエスト原文を保存するのは再処理のためで、レスポンス原文を保存するのは、再送に最初とまったく同じ答えを返すためです。
状態は5つです。
| 状態 | 意味 | 同じGUIDが再び来たら |
|---|---|---|
| SENT | 勘定系へ送信中(先取り済み) | E903。処理中なので、待たずにすぐ答えます |
| DONE | 確定した答えがある(0000・B2xx・E500) | 保存したレスポンスをそのまま返します |
| UNKNOWN | 送ったが、答えを受け取れていない(E901) | E901。勘定系を再び呼びません |
| FAILED | 送れなかったことが確実(E902) | 再送してかまいません |
| RECEIVED | 受け取ったが、まだ送る前 | 実装によって、SENTと同じに扱います |
送る前に書きます。核心は順序です。勘定系を呼び出したあとで元帳に書くと、その間にハブが死んだとき、元帳には痕跡がなく、チャネルの再送は新しい取引として処理されます。そこで、まずSENTで1行を先取り(claim)し、次に呼び出し、結果を書きます。先取りは原子的でなければなりません。2つのスレッドが同じGUIDを同時に受け取り、どちらも「ないな、自分が処理しよう」と判断すると、元帳があっても意味がありません。SQLiteのINSERT ... ON CONFLICT DO NOTHING(UPSERTのドキュメント)は、主キーが衝突したら何も挿入せず、影響を受けた行数で「自分が先取りしたか」を教えてくれます。照会してから挿入する2段階ではなく、挿入1回で判断します。
UNKNOWNは照会でのみ解決します。結果のわからない取引を再送するのは、賭けです。勘定系が処理していたなら、二重振込になります。代わりに、勘定系の照会APIで、そのGUIDが処理されたかを尋ねます。処理されていればその結果でDONE、記録がなければFAILED(未処理が確定)に変えます。ここに落とし穴が1つあります。たった今タイムアウトした取引は、勘定系がまだ処理中かもしれません。今照会して「なし」を受け取り、FAILEDに変えて再処理すると、1秒後に勘定系が最初のリクエストを処理し終えて、2回引き落とされます。そのため照会は、対象の最大処理時間より古いUNKNOWNだけを対象にします(--min-age)。
DLQは捨てる場所ではなく、待つ場所です。送れなかった取引(E902)は、原因が解消されたあとに再送すればよいものです。Dead Letter Channelは、処理できないメッセージを別に集めておくチャネルです。このラボでは、FAILED/E902の行がその役割です。再処理の原則は3つです。① 同じGUIDで送る(新しい番号を採番すると、元帳が重複を見つけられません)。② 試行回数に上限を設ける(永遠に失敗する取引が、毎周期、勘定系を叩き続けないように)。③ UNKNOWNは絶対に再処理の対象にしない。
元帳も掃除します。元帳は無限に大きくなれません。しかし、削除のルールがそのまま重複防止の限界です。DONEを7日後に削除するということは、「8日後に来た再送は防げない」という意味なので、保存期間は、チャネルが再送しうる最長の期間より長くなければなりません。そして、確定していない取引(UNKNOWN・FAILED)は、期間に関係なく残します。削除した瞬間に、その取引は調査する根拠を失うからです。
現場での姿
最もよくある事故は、再処理バッチが「失敗したものすべて」を再送してしまうことです。失敗の一覧にタイムアウトの件が混ざっていて、そのうち一部は、勘定系がすでに処理した件です。翌朝、カスタマーセンターに二重引き落としの問い合わせが殺到します。2つ目は、再処理のときにGUIDを新しく採番することです。元帳が同じ取引だと見抜く方法がなくなります。3つ目は、元帳をメモリ(ディクショナリ)に置くことです。ハブを再起動した瞬間に、「何を送ったか」を忘れます。4つ目は、元帳の照会と挿入の間の競合です。負荷の低い開発環境では決して表に出ず、チャネルが短い間隔で再送する障害のときにだけ、二重処理が起きます。
次のラボですること
元帳のスキーマを書き、フィクスチャの中継(relay_base.py、重複防止なし)をコピーして元帳を組み込みます。完了した取引の再送、処理中の再送(E903)、タイムアウト(UNKNOWN)と接続失敗(FAILED)の順に進めます。そのあと、UNKNOWNを照会で確定するresolve.py、未送信が確定した取引だけを同じGUIDで再送するreprocess.py、保存期間の整理purge.pyを作ります。採点ツールは、一時元帳と勘定系の呼び出し統計で、二重呼び出しを検出します。