二重に持っているデータ(正本と、そこから計算した値)がズレていたのに、絞り込みに使うまで誰も気づかなかった話です。
やろうとしていたこと
スマホ側で「web に出す」のチェックを外した写真が、web のページ作成画面と本棚に出続けていた。取り消しても写真そのものはサーバに残す仕様なので、これはデータの問題ではなく表示の線引きの問題だ。なので「公開中のものだけ出す」に絞ることにした。なぜ実体を消さず表示で外すことにしたかは「「外す」には3つの層がある — どこで外すかで、戻せるかどうかが決まる」に書いた。
サーバ側は1行で済みます。この手の実装は AI に書いてもらっているので、出てきたのもほぼこの1行だった。ただ名前が実態とずれていて、先に断っておく必要がある。スマホの「web に載せる」で立てている印は、DB では今も FavoriteMemory(正本)と Photo.is_favorite(導出)という名前のままです。以下コードに出てくる favorite は全部「web に出す印」だと思って読んでほしい。
# qs = 一覧として返す写真の集合(クエリセット)。?favorite=1 が付いていたら公開中だけに絞る
if self.request.query_params.get("favorite") in ("1", "true"):
qs = qs.filter(photo__is_favorite=True)テストは通った。ローカルの実データでも意図どおり減った。
ただ、このフラグを画面の絞り込み条件に使うのは初めてだった。コードが正しいことと、既に入っているデータが正しいことは別の話です。ここで念のため、フラグと実体が合っているかを数えてみた。
photos = Photo.objects.filter(owner=u) # 両辺を同じ owner に揃えるのが要点
# フラグが立っている写真の id 一覧
flagged = set(photos.filter(is_favorite=True).values_list("id", flat=True))
# 実体(正本)がある写真の id 一覧
actual = set(FavoriteMemory.objects.filter(owner=u).values_list("photo_id", flat=True))
# ^ は集合の演算子で、「どちらか片方にしかない id」だけを取り出す(両方にあるものは消える)
print(len(flagged), len(actual), sorted(flagged ^ actual))9 12 [1, 2, 27]
左から、フラグが立っている枚数(9)、実体がある枚数(12)、片方にしかなかった写真の id(1・2・27)。
3枚ズレていた。 お気に入りの実体(FavoriteMemory)はあるのに、フラグが立っていない。この3枚は絞り込みを入れた瞬間から「公開中なのに画面に出てこない写真」になる。
そもそも二重に持っているのは何なのか
毎回テーブルを結合して数えなくていい代わりに、書き込みのたびに計算し直さないとズレます。このプロジェクトでは、計算し直す関数を1つだけ用意して、そこを通すルールになっています。
def recalc_photo_is_favorite(photo) -> None:
"""Photo.is_favorite を FavoriteMemory の存在から導出する単一の書き込み口。
FavoriteMemory が正本・is_favorite は派生。複数 meaning に対応(1件でも残れば True)。
"""
desired = photo.favorite_memories.exists() # 正本を見る: この写真に FavoriteMemory の行が1件でもあるか
if photo.is_favorite != desired: # 導出の値とズレているときだけ書く
photo.is_favorite = desired
photo.save(update_fields=["is_favorite"]) # is_favorite の列だけ更新(他の列は触らない)書き込み経路を全部洗ってもらうと、同期の2箇所と、API の作成・削除の2箇所——4箇所すべてがこの関数を通っていた。つまり今のコードにバグはない。ならコードではなくデータの側を疑う番だ、と当たりをつけた。
いつ入ったかを調べたら答えが出た
ズレている3枚の FavoriteMemory が、いつ作られたかを見た。
1 is_fav False fav_created 2026-06-16T03:55:33
2 is_fav False fav_created 2026-06-16T03:55:33
27 is_fav False fav_created 2026-06-17T01:24:58
次に、さっきの関数がいつ入ったかを git で引いた。
# -S は「この文字列が増えた/減ったコミット」だけを探すオプション。関数が生まれた日が一発で出る
git log --oneline -S "def recalc_photo_is_favorite" -- backend/photos/core/services.py
# 228f339 feat(backend): お気に入りの正本を FavoriteMemory に一本化(is_favorite は派生)コミット日は 6月18日。ズレていたデータが作られたのは 6月16〜17日。
ルールを作っても、既に置いてあるデータは誰も直しに来ません。
なぜ1ヶ月気づかなかったのか
このフラグは、それまで画面の絞り込みに使われていなかった。値としては存在していたが、ズレていても何も起きません。
今回このフラグを一覧の条件に使ったので、初めて「フラグが false の写真は出ない」という結果に化けた。読む場所が増えるまで、ズレは結果に出てきません。
そしてもうひとつ、これはテストでは絶対に出ません。テストのデータは毎回テストの中で作るので、その時点のコード——つまり書き込み口を通ったデータ——しか存在しません。整合していて当たり前の世界でしか検証していません。
直し方は3つあった
出てきた選択肢は3つ。
- 正本を直接見る(
photo__favorite_memories__isnull=False— アンダースコア2つでテーブルをまたいで「ぶら下がる行があるか」を見る書き方 — で絞る)。ズレの影響を受けないが、導出フィールドをわざわざ持っている意味が薄れます。加えて結合すると写真1枚がぶら下がっている件数分の行になって返ってくるので、重複を潰す指定(distinct())も要ります - 計算し直す。全件に対して上の関数を流します。正本から導出するだけなので何度やっても同じ結果になりますし、正本には触りません
- そのデータごと消す
本来は 2 で、しかも正本を決めた 6月18日のコミットに data migration として同梱しておくべきものだった。is_favorite というカラム自体はもっと前からあって(FavoriteMemory テーブルよりも前だ)、当時は同期が直接書き込んでいた。それが「FavoriteMemory から導出する値」に変わったのが 6月18日。そのコミットのメッセージには 「マイグレーション不要」 と書いてある——書き込み口を1つにまとめれば足りると思っていた跡が、そのまま残っていた。既存の行を埋める必要が生まれるのは、フィールドを足した時ではなく「このフィールドは他から導出する」と決めた時だった。
(手で流すと、ローカル・検証・本番と環境の数だけ「あれ、これ流したっけ」が発生します。migration に入れておけば、その環境が最新まで上がった時点で必ず一度だけ走ります)
今回は 3 を選んだ。ズレていた3枚が、実はもう使っていない検証用アカウントのものだったからだ。ローカルの検証データを整理して、自分のアカウント1つだけにした。古いデータが残っているほど、次に何かが起きたときの原因の切り分けが重くなると判断した。そう判断するに至った前日の別件と、消す前に何が道連れになるかを機械に数えさせた手順は「「消していい?」に答えるために、何が消えるかを Django に数えさせた」に書いた。
消したのは 6/18 より前に作られた行だけで、残したアカウント側は 6/18 以降のデータしかない。書き込み口の4箇所が全部あの関数を通っている以上、これ以降に作られた行は原理的にズレません。今回のズレは「ルールができる前の時代」に限定された、一度きりの掃除だったことになる。
学んだこと
- 「このフィールドは他から導出する」と決めた日が backfill の期日。書き込み口を1つにまとめるだけでは足りず、その時点で既にある行を一度全部埋めるところまでで1セット。data migration に同梱すれば漏れません(今回はまさにそのコミットに「マイグレーション不要」と書いていた)
- 使っていない導出フィールドは、ズレていても誰も気づけません。読む場所を増やした日が、そのフィールドの最初の検査になります
- テストのデータは必ず整合しています。だからこの種のズレはテストでは検出できません。実データを一度数えるしかありません(
flagged ^ actualの1行で出ます)
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。