こんにちは。月謝袋アプリ開発の SINQWELL です。
今回は開発者向けの深掘り記事です。テーマは 「外部サービスと同期するアプリで、何をしたらデータが重複するのか」。
教材は、恥ずかしながら私自身の事故です。
何が起きたか
月謝袋アプリには、レッスン予定をGoogleカレンダーに同期する機能があります。
先日、YouTubeでこの機能の紹介動画を撮るために、撮影用の別Googleアカウントに
連携を切り替え、撮影後に元のアカウントへ戻しました。
ところが戻した後、同期がうまくいっていない気がして何度か同期ボタンを押したところ——
自分のカレンダーがこうなりました。
同じレッスンが3重。調べると、本来 819件 のはずのレッスン予定が 2,317件。
3重が701件、2重が68件、4重が6件、最大6重のものまでありました。
アーキテクチャのおさらい
同期の仕組みはシンプルです。
- レッスン1件 = Firestoreの1ドキュメント
- カレンダーに予定を作ったら、そのイベントIDをレッスン側に
googleCalendarEventIdとして保存 - 一括同期は「IDが無いレッスンだけ」イベントを作成(=二重作成防止)
- 連携解除は「IDがあるレッスン」のイベントを削除し、IDの記録も消す
一見、問題なさそうに見えます。実際、アカウントを1つしか使わなければ問題は起きませんでした。
原因①: 「削除できたか」を確認せずに記録を消していた
事故の主犯は連携解除の処理です。当時のコードは、概ねこうでした。
// 旧実装(ダメな例)
final success = await deleteLessonFromCalendar(eventId);
// ↓ successを見ずに、無条件で記録を削除していた
await lessonDoc.reference.update({
'googleCalendarEventId': FieldValue.delete(),
});
イベントの削除に 失敗しても、FirestoreのID記録は消える。
これが別アカウント接続中に致命傷になります。イベントの実体はアカウントAの
カレンダーにあるのに、接続中なのはアカウントB。Calendar APIはBのカレンダーから
削除しようとして失敗します。でも記録だけは消える。
結果、「カレンダーに予定は残っているのに、アプリは『未同期』だと思っている」
状態が完成します。この状態で同期を押せば、全レッスンがもう1セット作られます。
解除→同期を繰り返せば、その回数だけ増えます。
原因②: 一括同期に再入防止がなかった
もうひとつ、重複ペアの作成時刻を見ると 8〜10秒差 のものが多数ありました。
これは一括同期の二重実行です。
一括同期は「最初にレッスン一覧をまとめて取得→1件ずつ作成しながらIDを書き込む」
という流れでした。つまり、一覧のスナップショット時点とIDの書き込み時点にズレが
あります。1回目の実行がIDを書き込む前に2回目が走り出すと、2回目のスナップショット
にはIDがまだ無いので、全件が「未同期」に見える。古典的な TOCTOU(check と use の
間に状態が変わる)問題です。
UI側ではボタンを実行中に無効化していましたが、サービス層には何のガードもなく、
複数端末や画面の再構築をすり抜けたら終わり、という作りでした。
復旧: 2,317件の棚卸しと1,498件の削除
まず壊れたカレンダーの掃除です。アプリのデータ(Firestore)は一切壊れていない
ことが確認できたので、カレンダー側だけを直せばよい。
Claude(Cowork)のGoogleカレンダー連携で全イベントを取得し、
(タイトル, 開始時刻)でグループ化 → 各グループで作成日時が最新の1件だけ残す
というルールで、重複1,498件を一括削除しました。
「最新を残す」のがポイントです。アプリがFirestoreに記録しているIDは
最後の同期で作ったイベント のはずなので、最新を残せばアプリとカレンダーの
対応関係が維持され、以後のレッスン変更・削除も正しく反映されます。
(なお、Googleカレンダーは削除後も約30日ゴミ箱に残るので、この種の一括削除は
失敗してもやり直せます。)
対策: v1.22.2 で入れた3つのガード
1. 記録は「外部側の削除を確認できた時」だけ消す
// 新実装: 削除結果を3値で返す
enum CalendarDeleteResult { deleted, notFound, failed }
final result = await _calendarService.deleteEventChecked(eventId);
if (result == CalendarDeleteResult.failed) {
failedCount++; // 記録は消さない。次回の解除で再挑戦できる
} else {
// deleted または notFound(404/410) のときだけ記録を消す
await lessonDoc.reference.update({...});
}
404/410(もう存在しない)は「成功扱いにしてよい失敗」です。手動削除済みの
イベントで解除が詰まらないよう、deleted と同じく記録を消します。
一部でも failed があれば アカウントの切断はせず、件数を警告表示して
再試行してもらいます。未接続のまま解除が走った場合は、処理自体を中止します。
2. 再入防止 + 作成直前の再確認
サービス層に実行中フラグを置いて一括処理の二重実行を弾き、さらに
イベント作成の直前にレッスンドキュメントを読み直してIDの有無を再確認する
ようにしました。スナップショットのズレ由来の二重作成は、これで別端末からの
同時実行でも起きません。
3. 「どのアカウントと同期したか」を記録して照合
同期に使ったアカウントのメールアドレスを保存しておき、別のアカウントに
接続した状態で同期・解除・レッスン追加をしようとしたら、処理を中止して
警告を出すようにしました。
そもそも今回の事故は「別アカウントで操作できてしまう」ことが入口でした。
入口で止めるのが一番安い対策です。
教訓
今回の学びをまとめると、こうなります。
外部システムへの書き込み記録は、外部側の削除確認が取れた時だけ消す。
「とりあえず記録を消す」は、記録と実体の対応を壊す最悪の順序でした。
記録が消えた瞬間、アプリは外部に置いてきたデータを永久に見失います。
アカウント切替を伴う連携は、「どのアカウントと同期したか」を必ず保存して照合する。
接続状態(signed in かどうか)だけ見ていても、「誰として」接続しているかは
分かりません。
一括処理は、チェック時点と書き込み時点のズレを前提に設計する。
再入防止フラグと、書き込み直前の再チェック。どちらか片方ではなく両方です。
ちなみに今回のバグ、ユーザーさんからの報告はゼロでした。普通は連携アカウントを
切り替えたりしないからです。一番行儀の悪い使い方をするのは開発者自身——
それが本番データで起きたのは痛かったですが、ユーザーさんが踏む前に自分で踏めた、
と思うことにします。
月謝袋アプリ、今後もこんな調子で堅実に改善していきます。
