同じ GUID が二度来た — 台帳で止める
目標
中継層にGUIDを基準とする状態元帳(SQLite)を組み込んで二重処理を防ぎ、結果未確定の取引を照会で確定し、未送信が確定した取引だけを安全に再処理します。
なぜ重要なのか
チャネルはタイムアウトになると同じGUIDで再送し、勘定系は冪等ではありません。中継層が「このGUIDをすでに送ったか、結果を知っているか」を覚えていなければ、再送がそのまま二重振込になります。そして、失敗の一覧をそっくり再送する再処理が、最もよくある大事故です。結果のわからない取引と、送れなかった取引を区別する必要があります。
ステップ
/root/eaimw/dedup/schema.sqlを書いてください。テーブルtxには、guid(主キー)、tx_code、state(RECEIVED・SENT・DONE・UNKNOWN・FAILEDのみを許可するCHECK)、rsp_code、request(リクエスト電文のBLOB)、response(レスポンス電文のBLOB)、attempts(整数)、updated_at(datetime('now')形式の文字列)を置きます。2回適用してもエラーが出てはいけません(IF NOT EXISTS)。cp /opt/lab/fixtures/eaimw/dedup/relay_base.py /root/eaimw/dedup/relay.pyで始めてください。--db <경로>(プレースホルダーはパスです)引数を追加し、起動時に同じディレクトリのschema.sqlを適用します。勘定系を呼び出す前にGUIDで1行を先取りし(SENT)、結果を受け取ったらDONEとレスポンス電文を保存します。DONEのGUIDが再び来たら、勘定系を呼ばずに、保存したレスポンスをそのまま返します。- 先取りしたGUIDがまだ処理中(SENT)のときに同じGUIDが来たら、待たずにすぐE903で答えてください。先取りは原子的に行います(
INSERT ... ON CONFLICT(guid) DO NOTHINGの行数で判断)。 - 読み取りタイムアウト(E901)は元帳にUNKNOWN、接続失敗(E902)はFAILEDとして残してください。UNKNOWNのGUIDが再び来たら、勘定系を呼ばずにE901で答えます。FAILEDのGUIDが再び来たら、再送してかまいません(試行回数+1)。
/root/eaimw/dedup/resolve.py --db <경로> --core <URL> --min-age <초>(プレースホルダーはパスと秒数です)を作成してください。更新から--min-age秒以上経過したUNKNOWNだけを、勘定系の照会API(GET /v1/transfers/<guid>)で確定します。200ならDONE・0000とレスポンス電文(リクエストヘッダーから作ったR電文+照会結果から作った45バイトの本文)、404ならFAILED・E902です。照会中に接続エラーが出たら、そのままにします。振込を再送してはいけません。/root/eaimw/dedup/reprocess.py --db <경로> --core <URL> --max-attempts <N>(プレースホルダーはパスです)を作成してください。FAILEDかつrsp_codeがE902で、attemptsがN未満の行だけを、保存したリクエスト電文で同じGUIDのまま再送します(先取りしてからattemptsを+1し、結果はステップ2と同じルールで記録)。UNKNOWNは絶対に送りません。/root/eaimw/dedup/purge.py --db <경로> --days <N>(プレースホルダーはパスです)を作成してください。N日を過ぎたDONEだけを削除します。UNKNOWN・FAILED・SENTは、期間に関係なく残します。
参考
- 元帳はスレッドごとに別の接続を開きます:
sqlite3.connect(path, timeout=5, isolation_level=None)(自動コミット。文1つが原子的です)。 - 先取り:
cur = c.execute("INSERT INTO tx(...) VALUES(...) ON CONFLICT(guid) DO NOTHING", ...)のあとでcur.rowcount == 1なら、自分が先取りしたことになります。 - 採点ツールは、元帳(
/root/eaimw/dedup/relay.db)には触れず、--dbで新しい一時元帳を渡して確認します。そのため、スキーマの適用は中継の起動時に行う必要があります。 - 障害の再現: メモが
SLOWで始まると、勘定系は遅れて(それでも処理は)行います。呼び出し統計curl -s localhost:9201/_statsのby_guidが、GUIDごとの呼び出し回数です。 - よくある間違い: 照会してから、なければ挿入するという2段階で先取りしてしまうこと(同時再送で両方が通過します)、再処理で新しいGUIDを採番してしまうこと、UNKNOWNを再処理の対象に入れてしまうこと。
状態元帳のスキーマ
/root/eaimw/dedup/schema.sqlに、txテーブル(guidが主キー、状態のCHECK、リクエスト・レスポンスの原文、試行回数、更新時刻)を書いてください。
CREATE TABLE IF NOT EXISTSで、2回適用しても安全にします。CHECK (state IN (...))が、誤った状態の書き間違いを防ぎます。原文はBLOBです。
完了した取引の再送。保存した答えをそのまま返す
relay_base.pyをコピーして元帳を組み込んでください。送る前にSENTで先取りし、結果はDONEとレスポンス電文で保存し、DONEの再送には保存したレスポンスをそのまま返します。
handleでcall_coreを呼ぶ前に、INSERT ... ON CONFLICT(guid) DO NOTHINGで1行を先取りし、すでにあればSELECTで状態を見ます。起動時にschema.sqlをexecutescriptで適用してください。
処理中の再送。すぐE903
SENT状態のGUIDが再び来たら、待たずにE903で答えてください。
先取りに失敗したのにDONEでなければ、誰かが処理中です。照会してから挿入する2段階ではなく、挿入1回の行数で判断すれば、同時再送でも片方だけが先取りします。
わからないものはUNKNOWN、送れなかったものはFAILED
E901はUNKNOWN、E902はFAILEDとして残してください。UNKNOWNの再送は呼び出さずにE901、FAILEDの再送は再送信します。
結果を書くときに、応答コードによって状態を選びます。FAILEDの再先取りも、UPDATE ... WHERE state='FAILED'の行数で原子的に行います。
照会で確定する。再送はしない
/root/eaimw/dedup/resolve.pyが、古いUNKNOWNだけを照会APIで確定するようにしてください(200→DONE、404→FAILED/E902、エラー→そのまま)。
updated_at <= datetime('now', '-N seconds')で、古いものだけを選びます。たった今タイムアウトした取引は、勘定系がまだ処理中かもしれません。レスポンス電文はlhstd.reply(リクエスト電文, '0000', 本文)です。
未送信が確定したものだけを同じGUIDで再処理する
/root/eaimw/dedup/reprocess.pyが、FAILED/E902で、attemptsが上限未満の行だけを、保存したリクエストで再送するようにしてください。
対象の選択条件がすべてです。state、rsp_code、attemptsです。送る前にSENTで先取りし直し、attemptsを上げます。GUIDは、保存したリクエスト電文のものをそのまま使います。
元帳の整理。削除してよいものだけ
/root/eaimw/dedup/purge.py --days Nが、N日を過ぎたDONEだけを削除するようにしてください。
DELETEのWHEREに、状態と期間の両方を掛けます。UNKNOWNを削除すると、その取引は調査する根拠を失います。