イベント駆動の整合性Event-Driven Integrity
データベースの更新と、その後の通知メッセージの送信は、本来それぞれ別の処理です。そのため「更新はできたが通知は送れなかった」という食い違いや、同じ通知が重複して届くことが起こり得ます。こうした事態を防ぐため、3 つの仕組みを実装しました。送り出し側の Outbox(アウトボックス)パターン、流通を担う Pub/Sub(パブサブ。発行・購読型のメッセージ基盤)、受け取り側の 2 層の冪等性(べきとうせい。何度処理しても結果が変わらない性質)です。
なぜこの仕組みが要るのかThe problem
松葉では「ファイルがアップロードされた」「ウイルス検査が終わった」などの出来事(イベント)を、別のサービスへ伝えて連鎖的に処理します。このとき注意すべき点が 2 つあり、それぞれに対処しました。
送り出し: DB の更新とメッセージ送信は本来別系統のため、「DB は更新できたが、送信前に障害でメッセージが失われる」ことが起こり得ます。→ Outbox パターンで対処します。
受け取り: Pub/Sub の配送保証は「最低 1 回(at-least-once。確実に届く代わりに、まれに同じものが 2 回届く)」です。→ 受信側の冪等性で対処します。
送り出し役(dispatcher)が 1 分ごとに未送信を拾って publish。
重複の早期排除 + 外部副作用の効果層、二重で重複処理を防止。
上限回数まで失敗したメッセージは捨てず退避キュー(DLQ)へ保全。
業務イベント用 13 種と、同数の退避先 DLQ(前段で整備済の流通路)。
送り出し側 — Outbox パターンOutbox pattern
メッセージをその場で直接送るのではなく、業務データの更新と同じトランザクション(まとめて成功/失敗する一区切り)の中で、いったん outbox_events という発信箱テーブルに書き留めます。送信は別系統の処理(dispatcher)が後追いで確実に行うため、ネットワーク障害が起きてもメッセージを取りこぼしません。
到達点: 発信箱テーブル・運び手(dispatcher)・発行までをコードで実装し、本番にデプロイ済みです。実際にイベントを積む業務処理(左の破線。例: 共有リンク発行)は、今後の開発で追加します。dispatcher は 1 分間隔で動き、複数同時に動いても同じメッセージを二重送信しないための取得ロック(FOR UPDATE SKIP LOCKED)を使っています。
| 発信箱の状態 | 意味と扱い |
|---|---|
pending | 送信待ち。運び手が次回拾って publish する。 |
published | 送信完了。published_at に時刻を記録。 |
dead_letter | 送信の再試行が 上限回数に達した。手動対応のため退避(試行のたびに attempts を加算)。 |
設計上のトレードオフ: 「いったん溜めて 1 分ごとに送る」ため、送信に最大 1 分の遅延が出ます。これは「確実に届ける(取りこぼさない)」ことと引き換えに受け入れた、意図的な設計判断です。
受け取り側 — 2 層の冪等性Idempotency
Pub/Sub は「最低 1 回」配送のため、まれに同じイベントが 2 回届きます。そのまま処理すると「同じ案内メールが 2 通届く」「同じファイルが二重に登録される」といった不都合が生じます。これを 2 つの層で防ぎます。
第 1 層(早期排除): processed_events に処理済みの event_id を記録し、2 回目以降は業務処理をスキップして即応答します。このテーブルはシステム専用権限(service_role)でのみ読み書きでき、業務ユーザからは触れません。
到達点: 送信元検証(OIDC)・既処理チェック・記録の枠組みを実装。中央の業務処理(破線。例: Drive 同期)は、今後の開発で追加します。
第 2 層(効果層の冪等性): メール送信や Google Drive 操作のような外部への副作用は DB の取り消し(ロールバック)が効きません。そこで処理そのものを「何度実行しても同じ結果」になるよう設計します。例: Drive 同期は Drive 側の付帯情報(appProperties)と突き合わせて重複作成を防ぎ、通知は宛先ごとの送信状態を見て二重送信を防ぎます。第 1 層だけでは防ぎきれない領域を、この第 2 層が受け持ちます。
取りこぼさない — 2 つの再試行と退避先Two retry layers & DLQ
再試行の仕組みは 独立した 2 つの層に分かれています。混同しやすいため、図で分けて示します。送り出し(Outbox)側と、流通(Pub/Sub)側は、別々に「相手に届くまで」を担保します。
上段は「まだ送り出せていない」メッセージの再試行(発信箱の中の話)、下段は「送り出した後、受信側が受け取れない」ときの再試行(流通路の中の話)です。両者は別々のしきい値・別々の退避先を持ちます。
なぜ 2 層に分けるのか: 送り出す前の失敗(DB やネットワーク)と、送り出した後の失敗(受信側の不具合)は、原因も対処も異なります。層を分けることで「どこで詰まったか」が切り分けられ、退避先(DLQ)の中身を見れば原因の見当がつきます。13 種類のイベントすべてに同じ構造の退避先を 1 対 1 で用意しています。