検証用のデータを消すときに、実行する前に「何が消えるか」を機械に列挙させた記録です。
消すと決めたところから
ローカルの開発用データベースに、アカウントが2つあった。ひとつは自分の実データ、もうひとつは初期に作った検証用(写真25枚が入っていて、テンプレの見た目を確かめるのに使っていた)。
古い方を消すかどうか迷った。自分としては残す方に材料があった。APIは所有者で絞るので検証用のデータは画面に出てこない=邪魔をしていないし、消すとテンプレの見た目を実データで確認する手段が減ります。
それでも消す判断をした。理由は原因の切り分けだった。前日に「存在しないユーザーを指したトークンのせいでログイン後の画面が全部401」という別件を踏んでいて、この日も古いデータ由来のフラグのズレを見つけていた(「公開中のはずの写真が3枚だけ出てこない — 導出フィールドの backfill 漏れ」に書いた3枚が、まさにこの検証用アカウントのものだ)。古いデータが残っているほど、次に何かが起きたときに原因の候補が増えます。
決めたのはここまで。問題は、消した先に何がぶら下がっているかを把握していなかったことだ。
消す前に、何が消えるかを数える
Django には、その連鎖を実行せずに集めるだけのクラスがあります。
from django.db.models.deletion import Collector
u = User.objects.get(username="...")
c = Collector(using="default") # using = どのデータベースを見るか(既定は "default")
c.collect([u]) # ← ここでは削除しない。消える対象を集めるだけ
for model, objs in c.data.items():
# 集めた結果は「モデルごとの箱」になっている
print(model.__name__, len(objs))出てきたのがこれだった。
User 1
Photo 25
Meaning 27 ← 写真に綴った言葉。1枚に複数つくので写真より多い
TemplatePage 4 ← テンプレで作ったページ
TemplatePage が4件。これが引っかかった。作ったページが4つ道連れになる。中身を確認したら、全部タイトルが「無題のページ」で写真0枚——作りかけて放置した実験の残骸だった。それで手が止まらずに済んだ。
もし中身の入ったページが出ていたら、そこで止めて「これも消えるけどいい?」と聞き直していた。数えているのは実行するためではなく、止まるためだ。
数えたのに、一覧に出てこないテーブルがある
ひとつ引っかかったのは、c.data に出てこないモデルがあることだった。お気に入りのテーブルが12件あるはずなのに、上の一覧に無い。
for q in c.fast_deletes:
# q は「まだ実行していない絞り込み条件」なので、件数は count() で数えにいく
print(q.model.__name__, q.count())FavoriteMemory 12 ← お気に入り登録
PhotoStatus 25 ← 写真ごとの状態(保留・非表示など)
SharedPage 1 ← 共有リンクの設定
...
共有リンクが1本消えることも、ここで初めて見えた。同じモデルが複数回出てくることがあるのは、参照経路ごとにクエリが分かれるためです。
消し方は、実装済みの退会APIを使った
シェルから直接消すこともできたが、退会機能(DELETE /api/accounts/me/)を前日に AI と実装したばかりだったので、そちらを通した。実データで動かす2回目の機会になる。書いてもらったコードが実データで想定どおり動くかは、こうして自分で数えるまで分からない。それに、シェルから user.delete() を叩くと cascade しか走りません。順序を組んであるのは退会APIの中なので、そこを通さないと画像の実体は消えないままになります。
ここで確認したかったのは、画像の実体まで消えるかだった。
退会の処理は「画像の実体 → ユーザー」の順で消すように組んであります。画像の削除が失敗したらそこで止まって 500 を返します。ユーザーのレコードはまだ残っているので、もう一度退会を押せば残りの画像から再試行できます。
実行前後で、ファイルが実際にディスク上にあるかを数えた(.path はローカルのファイル保存前提の書き方。本番は S3 互換のストレージなので、同じ確認はそのままでは使えません)。25行あるうち、画像の実体を持っていたのは10件だった。
# .path = ディスク上の実ファイルの置き場所(DB の行ではなく実体)
# bool(FieldFile) は "" も None も False になる=実装側の delete_user_media と同じ弾き方
paths = [p.image.path for p in Photo.objects.filter(owner=u) if p.image]
print("BEFORE:", sum(1 for p in paths if os.path.exists(p)), "/", len(paths))
# → BEFORE: 10 / 10
# (ここで DELETE /api/accounts/me/ を叩く → 204 = 成功・返す中身は無し)
print("AFTER :", sum(1 for p in paths if os.path.exists(p)), "/", len(paths))
# → AFTER : 0 / 10消えていた。最後に、残ったレコードを数えて孤児——親が消えたのに取り残された子のデータ——がいないことも確認した(写真2・綴り4・共有0——すべて残す側のアカウントのぶんだけ)。
学んだこと
- 「これ消していい?」に答える道具が
deletion.Collector。見積もりでも grep でもなく、実行せずに機械が列挙します。消す前の数十秒で済みます c.dataとc.fast_deletesの両方を見ます。片方だけだと、連鎖の末端(今回は共有リンク)が見えません- 数えた結果に惜しいものが混ざっていたら、そこで止めて聞きます。今回は
TemplatePage4件が全部空ページだったので、止めずに済んだだけだ - 消したあとに「孤児が残っていないか」を数えるところまでで1セット。cascade は DB の行しか消さないので、ストレージのファイルは別で確認します
- 取り消せない処理を2段階でやるときは、途中で落ちたときにどっちが残っていればやり直せるかで順序を決めます。 今回なら「画像 → ユーザー」。逆だと、失敗した時点でやり直す手がかりごと消えます
- 実装は AI に書いてもらっているが、「消す前にどこまで確認するか」「どこで止まるか」の線引きは自分が引くところだった。コードが正しく動くことと、実行していいことは別
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。