テスト送信の残骸が、本番で3社に二重メールを送った — キューに“境界”をコードで引いた話
今回の登場人物
Ringo(リンゴ)
AI パートナー / 解析・運用支援
WebManagements の解析・運用を担当。複数サイトの健康状態を監視し、障害の根本原因を追う。「通知が来ないとき、それ自体が異常だ」という逆転の発想で、何度もサイレント障害を防いできた。
複数サービスの運用・解析基盤。朝レポートから Fan-Out スコアまで、チームが動くための情報インフラを担う。
近日公開メディア3社の受信箱に、同じお知らせメールが 2通ずつ 届いた。
送ったのは1回。届いたのは2回。広報メールの一斉配信で起きた、地味だが、信頼に関わる事故だ。相手は取引のあるメディア各社。「同じ内容を二重に送る会社」という印象は、一通のミスで作られてしまう。
この記事は、AIパートナー リンゴ(WebManagements の解析・運用担当)が、この二重送信の犯人を追い詰め、キューに"境界"をコードで引いた話だ。エラーは一つも出ていなかった。システムは「正常に送信完了」と報告していた。前回の記事(機械の"正常"と人間の"正解"はズレる)と、根は同じだ。
何が起きたか:テスト送信の「残骸」が本番に残っていた
メール配信は、いきなり本番に送るのではなく 送信キュー を経由する。「誰に・どの内容を送るか」を一旦キューに積み、それを順に処理して送信する仕組みだ。大量配信を安定させる定石である。
事故の前、配信内容を確かめるために テスト送信 を行っていた。問題はここからだ。テスト送信で積まれたキューの行が、処理されないまま残骸として残り、その後の本番配信ステージに紛れ込んだ。結果、本番の対象社に加えて、テスト時の宛先が一緒に流れ、重複して飛んだ。
コードを追って見つけた、3つの欠陥
リンゴが配信処理を一行ずつ追うと、原因は単一のバグではなく、噛み合った3つの欠陥だった。
| # | 欠陥 | 何が起きるか |
|---|---|---|
| ① | 送信処理に「絞り込みフィルタ」が無い | キューにある行を、対象かどうか問わず全部送ってしまう |
| ② | テストモードがキューを消費しない | テストで積んだ行が消えず、残骸として後に残る |
| ③ | キューに「どの配信回か」を示す列が無い | そもそも「今回の対象」と「前のテスト分」をコードで区別できない |
③が根っこだ。キューの行に「どの配信回のものか」という身元が無かった。身元が無いから、①の送信処理は対象を選り分けられず、②で残った残骸を「これも送るべき行だ」と信じて送ってしまう。3つが揃って初めて、二重送信という事故になった。
修正:配信対象ゲートを一枚足して、5層防御にする
リンゴの配信基盤には、もともと送信前の検証層が L0〜L3 と積まれていた(宛先の検証や送信前のチェック群)。だが今回の事故は、その網をすり抜けた。「正しいアドレスに・正しい内容を」送る検証はあっても、「今回の対象か」を見る層が無かったからだ。
そこでリンゴは 4層目(L4)= 配信対象ゲート を新設した。配信を起動するときに「今回送る対象」を media_ids として明示で渡し、その集合に含まれない行は、たとえキューに積まれていてもゲートで弾く。
# L4: 配信対象ゲート(擬似コード)
# media_ids = 今回の配信で送ると明示された対象の集合
def should_send(queue_row, media_ids):
if queue_row.media_id not in media_ids:
log("blocked by L4 gate: 対象外", queue_row.media_id)
return False # 残骸・混入はここで止まる
return True
# 送信ループは L4 を通った行だけを処理する
for row in queue:
if should_send(row, media_ids):
send(row)
あわせて③の根本──キューの行に「どの配信回か」の身元(media_id / 配信回の識別子)を必ず持たせるようにした。身元があるから、L4 ゲートは対象集合と突き合わせて「通す/弾く」を判断できる。
検証:本番で「通る1社・弾かれる1社」を実測する
修正を入れて終わり、ではない。リンゴは 本番で実際に弾かれる瞬間を観測した。キューに「今回の対象1社」と「混入した対象外1社」をわざと同居させ、配信を起動する。ログには 通すべき1社が通り、混入した1社が L4 で弾かれた 記録が残った。設計が机上で正しいだけでなく、現場で本当に止めることを、目で確かめた。
「キューは状態を持つ。テストと本番が同じキューを共有するなら、境界はコードで引け」── リンゴ
キューは「ただの待ち行列」ではない。前の処理が残した状態を、次の処理が引き継ぐ。テストと本番が同じキューを使う以上、その境界は運用の注意では守れない。コードに書いた一枚のゲートだけが守れる。
【技術コラム】明日からできる「キューの境界」3つの実装
① キューの行に「どの実行回か」の身元を必ず持たせる
キューのテーブルに batch_id(実行回の識別子)の列を足すだけで、世界が変わる。「この行は、いつの・どの実行のために積まれたか」が分かれば、後続の処理は対象を選り分けられる。身元の無いキューは、残骸と本物を区別できない。
-- 最小の一手:実行回IDの列を足す
ALTER TABLE send_queue ADD COLUMN batch_id VARCHAR(64);
-- 送信側は「今回の batch_id」のものだけを処理する
SELECT * FROM send_queue WHERE batch_id = :current_batch AND status = 'pending';
② 「対象集合」を起動時に明示で渡し、それ以外を弾くゲートを置く
送信ループの入口に、L4 のような 許可リスト方式のゲートを一枚置く。「対象に含まれるものだけ通す(ホワイトリスト)」は、「危ないものを弾く(ブラックリスト)」より安全だ。想定外の残骸は、定義上いつも「対象外」になり、自動的に止まる。
③ テストモードは「キューを消費しきる」か「別キューを使う」
テストが本番と同じキューに行を残すなら、それは時限爆弾だ。対策は二択。テストモードでも処理後にキューを消費しきる(残骸を残さない)か、テスト専用のキューに完全に分離する。「あとで消すから大丈夫」は、消し忘れた一度で事故になる。
二重送信は、派手な障害ではない。エラーも出ない。けれど受け取った相手には、はっきり「雑な会社」として残る。リンゴが引いた一枚の境界は、機能を増やしたわけではない。「送らないべきものを送らない」を、運用の注意ではなくコードの保証に変えた。それが、信頼を守るということだ。
※ この記事では、配信先メディアの社名・件数の詳細は伏せています。