お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、アカウント復旧メールの再試行に関する設計上の問題が見つかりました。DevPickは、開発関連のニュースサイトをAIが横断してスコアリングし、その人の興味に合ったものだけを届けるWebアプリです。
メール送信を確実にするためにOutboxを導入し、失敗時に再試行できるようにしていました。しかし、送信先のサービスには受理されているのに、その応答だけを受け取れなかった場合、再試行によって先に届いた復旧リンクが無効になる作りでした。今回は「送れたか分からない」という曖昧な状態を前提に、復旧コードの持ち方を見直した経緯を書きます。
Outboxにしただけでは、復旧メールは安全にならなかった
DevPickのアカウント復旧では、メール送信をOutboxで管理しています。Outboxは、送るべきメールを先にデータベースへ記録し、別の処理が後から取り出して送る仕組みです。送信処理が一時的に失敗しても記録が残るため、再試行できます。
以前は、復旧コードをメール送信の依頼時に発行していました。しかしOutboxで実際に送るまで時間が空くと、利用者の手元に届いた時点で有効時間が短くなってしまいます。そこで、実際にメールを送る直前にコードを発行し、そこから30分間使えるように変えていました。
この変更だけを見ると、利用者が使える時間をきちんと確保できています。問題は、プロフィールごとに保存できる復旧コードが1つだけだったことです。再試行のたびに新しいコードを発行すると、以前のコードは上書きされます。「前回の送信は失敗した」と断定できるなら、それでも困りません。ところが外部サービスへの送信では、成否を断定できない場合があります。
バックグラウンド処理は、表の画面から見えにくいぶん、起動方法や再試行時の状態まで含めて考える必要があります。以前書いたバックグラウンド処理が静かに消えうる問題とは原因が異なりますが、どちらも「処理を裏へ回した後に何が起きるか」が設計の中心でした。
IT女子 アラ美「送信失敗」ではなく「送信結果が分からない」が問題だった
メール送信にはResendという外部サービスを使っています。アプリがResendへ送信を依頼し、成功の応答を受け取れれば、送れたと判断できます。明確なエラーが返れば、失敗したと判断して再試行できます。
厄介なのは、その中間です。Resendがメールを受理した直後に接続が切れたり、応答の読み取りが時間切れになったりすると、アプリには成功の応答が届きません。アプリから見ると失敗ですが、外部サービスではすでに送信処理が進んでいる可能性があります。つまり「失敗」ではなく、「送れたかどうか分からない」状態です。
以前の設計では、次の順序で問題が起きます。
- 最初の送信で復旧コードAを発行し、プロフィールへ保存する
- ResendはコードAを含むメールを受理する
- 成功の応答だけがアプリへ届かず、送信失敗として扱われる
- 再試行でコードBを発行し、保存済みのコードAを上書きする
- 利用者には最初のメールが届くが、リンク内のコードAはすでに使えない
利用者から見れば、届いたばかりで有効期限内のリンクです。それなのに開くと無効と判定されます。再試行で2通目のメールも届けば、そちらは使えます。しかし、利用者に古いメールと新しいメールを見分けてもらう設計は不親切ですし、2通目も確実に届くとは限りません。
この問題から、送信処理では「成功」と「失敗」の2択だけを考えてはいけないと分かりました。結果不明を再試行するなら、先の処理が実は成功していた可能性を壊さないことが必要です。



復旧コードを上書きせず、プロフィールごとに追加する
直し方の中心は、プロフィールに復旧コードを1つだけ持たせるのをやめることでした。復旧コード専用のテーブルを用意し、1つのプロフィールに対して複数のコードを保存できる形へ変えています。
再試行で新しいコードを発行するときは、有効期限が切れたコードだけを削除し、新しいコードを1行追加します。まだ有効なコードは残すため、最初の送信が実は成功していても、そのメールに入っているリンクは引き続き使えます。再試行で発行したコードも同時に有効なので、どちらのメールが先に届いても問題ありません。
保存するのは復旧コードそのものではなく、SHA-256で計算したハッシュ値です。ハッシュ値は、元の値から一方向に変換した照合用の値です。利用者がリンクを開いたときも、リンク内のコードを同じ方法でハッシュ化し、保存済みの値と一致するかを確認します。データベースの内容だけを見ても、復旧に使える生のコードが分からないようにするためです。
メールに埋め込む生のコードは送信時にだけ使い、送信処理が終わった後は保持しません。有効期限が切れた行は、次のコードを発行するタイミングに加えて、定期的な削除処理でも片付けます。複数持てるようにした結果、古いコードが無制限に残ることは避けています。
この変更で大事なのは、単に保存先を別テーブルへ移したことではありません。「再試行は前の試行を失敗と決めつけない」という考え方を、データの持ち方に反映したことです。送信結果が分からない以上、有効期限内の候補を共存させるほうが実際の通信に合っています。



どのリンクでも復旧でき、1つ使ったら残りも失効させる
複数の復旧コードを同時に有効にすると、再試行に強くなる一方で、使える入口も増えます。そこで、どれか1つのコードで復旧が完了したら、同じプロフィールに紐づく残りのコードをすべて削除するようにしました。
たとえば、送信結果が分からず3回試行し、3通とも届いたとします。利用者は、そのうちどのリンクを開いても復旧できます。ただし、最初のリンクで復旧した後は、残り2つのリンクは使えません。再試行の都合で入口は複数作っても、復旧できる回数は1回に保つ考え方です。
復旧に使われたコードの削除、同じプロフィールに残るコードの一括削除、復旧後の認証情報の更新は、1つのトランザクションにまとめました。トランザクションは、複数のデータ更新をひとまとまりとして扱い、すべて成功するか、すべて取り消すかのどちらかにする仕組みです。途中までしか反映されない状態を防げます。
同時に2つのリンクが開かれた場合も考慮しています。先にプロフィールの行をロックしてからコードを削除し、同じ利用者の復旧処理が並行して進まないようにしました。さらに、SQLiteでは行ロックの指定がそのまま効かないため、対象コードを削除できた行数も確認します。すでに別の処理が使っていて削除件数が0なら、復旧を続けず無効として扱います。
ここは「たぶん先に消されていないだろう」と進めるより、確認できなければ止める側へ倒しています。複数コードを許す変更は利便性のためですが、そのぶん利用済みコードを再び通さない境界は厳しくする必要がありました。



移行の瞬間に、すでに届いたリンクを切らない
データ構造を変えるときは、新しい処理だけ正しくても足りません。変更を反映する直前に発行され、すでに利用者へ届いている復旧リンクがあるかもしれないからです。プロフィールから古い保存欄をそのまま削除すると、そのリンクは有効期限内でも使えなくなります。
今回の移行では、まず復旧コード用の新しいテーブルを作り、プロフィールに保存されていた既存のコードと有効期限を新しいテーブルへコピーしました。その後で、プロフィール側にあった古い保存欄を削除しています。これにより、変更前に発行したリンクも、新しい照合処理から見つけられます。
この順序は地味ですが、利用者にとっては重要です。障害を直すための変更が、反映した瞬間に別の無効リンクを作ってしまっては意味がありません。新旧のデータ構造を入れ替えるときは、保存されている値だけでなく、その値がすでにメールや通知として外へ出ている可能性まで見る必要があります。
コードを1つから複数へ変える作業は、テーブル設計だけなら小さな変更に見えます。しかし実際には、送信中の不確実さ、利用時の競合、変更前に発行したリンクの継続性までが1つにつながっていました。外部へ渡した値は、データベースの列を消しただけでは回収できないという当たり前のことを、移行手順にも反映しています。



まとめ
今回の問題は、メール送信の再試行そのものではなく、「前の送信は失敗した」と扱って復旧コードを上書きしていたことにありました。外部サービスがメールを受理した後に応答だけが失われると、アプリには結果が分かりません。その状態で前のコードを消すと、実際には届いているリンクまで無効にしてしまいます。
対策として、復旧コードをプロフィールごとに複数持てるようにし、有効期限内のコードを上書きせず残しました。どのリンクでも復旧できる一方、1つが使われたら残りを同じトランザクションですべて失効させます。変更前に発行済みだったコードも新しいテーブルへ移し、移行によって届いたリンクを切らないようにしました。
今の私の結論は、外部サービスを使う処理には「成功」「失敗」だけでなく「結果不明」があるものとして設計することです。再試行できるだけでは十分ではなく、前の試行が実は成功していた場合にも利用者を困らせないことまで含めて、初めて再試行に強い仕組みになると考えています。












