Skip to content

イベント削除で写真と動画の実体も消す (#424) - #484

Merged
kojira merged 3 commits into
mainfrom
fix/event-delete-r2-424
Sep 4, 2026
Merged

イベント削除で写真と動画の実体も消す (#424)#484
kojira merged 3 commits into
mainfrom
fix/event-delete-r2-424

Conversation

@kojira

@kojira kojira commented Sep 4, 2026

Copy link
Copy Markdown
Member

Closes #424

イベントを削除しても R2 のオブジェクトが残り、動画 (#408) で1イベント最大2GB の孤児が出うる状態を直す。ついでに、同じことをしている経路をひとつの契約に寄せた。

棚卸し(メディアを消しうる経路すべて)

経路 修正前 修正後
イベント削除 routes/eventCrud.ts D1 の行だけ。R2 は全部残る 収集 → D1 → R2
写真・動画1件の削除 routes/eventPhotos.ts 実体は消えるが R2 が先(行だけ残りうる) D1 → R2
イベント画像の削除 routes/images.ts 実体は消えるが R2 が先 D1 → R2
景品画像の削除・差し替え routes/eventMeetPrizes.ts 順序は正しいが書き方が独自 共通の deleteObjects に統一
退会の完全 purge lib/purgeDeleted.ts 順序は正しいが、purge が消す下書きイベントの表紙・景品画像を取り残していた collectEventObjects を通す
運営のモデレーション routes/adminModeration.ts 非表示にするだけで実体は残す(対処の証跡) 意図どおり。変更なし

コミュニティ削除と、routes/bgm.ts / routes/venues.ts の R2 先行削除は範囲外として #483 に切り出した。過去の削除で既に残っている孤児の掃除は #482

選んだ失敗方向: 孤児に倒す

D1 の書き込みと R2 の複数削除は原子的にできない。どちらを先にしても途中で落ちうるので、どう壊れるかを選ぶ話になる。

  • R2 が先に成功して D1 が失敗 → 参照はあるのに実体が無い。一覧に出るのに開けず、行から復元する手段が無い。回復不能
  • D1 が先に成功して R2 が失敗 → 誰からも参照されない実体が残る。配信はされず、prefix を舐めれば後から拾える。回復可能

よって契約は「D1 からキーを集める → D1 を消す → R2 をベストエフォートで消す」。行が消えた後はキーを辿れないため、収集は必ず削除より前に置く。実装は apps/server/src/lib/mediaCleanup.ts の1か所に集約し、キーの組み立ても全経路がここを通る。

deferBackground には逃がしていない。収集はどのみちインラインになり、残る R2 側は multi-delete 1回(1回1000キー、イベント写真は上限50本)。サブリクエスト予算50に対して余裕があり、インラインなら失敗がテストとレスポンスから見える。

列挙は bucket.list ではなく D1 から行う。何がそのイベントの持ち物かを知っているのは D1 で、景品画像のキーは乱数入りで D1 にしか無いため。掃除用の SELECT は表示用の絞り込みを通さない(運営が非表示にした写真を落とすと、その実体だけが残る)。

レビューで見つかった取りこぼし: 収集する集合と削除する集合がズレていた

退会の完全削除は「参加者のいない下書きイベント」の行も消す。最初の修正では、収集の SELECT が 付け替え前の本人名義、削除の DELETE が 付け替え後の ghost 名義 を見ていた。しかも DELETE は本人のイベントに限定されておらず、条件に合う ghost 名義の下書きをすべて消す。

再現する筋書き:

  1. A の下書きイベントに第三者 B が参加している。A が退会 → 参加者が居るので削除も収集もされず、イベントは ghost 名義で残る。
  2. B がそのイベントから抜ける。
  3. 無関係な C が退会 → DELETE が ghost 名義の参加者ゼロの下書きに当たって行を消すが、C の収集はそのイベントを見ていない。行だけ消えて event-images/{id} が R2 に残る。

条件文字列を共有するだけでは足りず、同じ引数で呼べることが要る。所有者の条件を created_by IN (本人, ghost) にして、SELECT と DELETE を同じ SQL・同じ引数 [userId, ghostId, userId] で呼ぶようにした。付け替えは statusevent_member も触らないので「付け替え後に created_by = ghost」⇔「付け替え前に created_by ∈ {本人, ghost}」が成り立ち、2つの集合は似ているのではなく同一になる。

変異テスト(使い捨てコピーで実施、各変異は shasum で適用を確認)

変異 結果 捕まえたテスト
collectEventObjects から景品画像のキーを落とす RED 表紙画像・写真・動画・景品画像がすべて消える
collectEventObjects から表紙画像のキーを落とす RED 同上
photoObjectKeys からポスターのキーを落とす RED 4本(既存の event-videos の削除テストを含む)
listIdsByEvent が運営非表示の行を除外する RED 運営が非表示にした写真・動画の実体も消える
listImageKeysByEvent から image_key IS NOT NULL を外す RED R2 に渡すキーが全部文字列であることを見るテスト
deleteObjects を no-op にする RED 8本
イベント削除で R2 を先に消す RED 順序を直接見るテスト
写真1件の削除で R2 を先に消す RED 同上
表紙画像の削除で R2 を先に消す RED 同上
退会 purge で R2 を先に消す RED 同上(本人とイベント両方の行数を見る)
MAX_KEYS_PER_DELETE を 1000 → 5000 RED 1回の delete に 1000 キーを超えて渡さない
deleteObjectsreturn callsreturn 0 RED 2本(予算の積み上げ)
退会 purge が下書きイベントの収集を飛ばす RED 完全削除で消える下書きイベントの実体も消える
収集を本人名義の下書きだけに絞る(上記の取りこぼし) RED 他人の退会で消える ghost 名義の下書きも実体ごと消える

順序の変異は R2 が正常なら結果が同じで、deleteObjects が例外を飲むため「R2 が落ちる」テストでも通ってしまう。順序そのものを見るテスト(stub した delete の中で D1 の行数を数える)を4経路すべてに置いて捕まえている。分割の契約も、テスト用バケットが1000キー超を受け付けてしまうため件数を直接見ている。新規テストは修正前のツリーでは全部落ちることを確認済み。

検証

  • pnpm -r typecheck クリーン
  • サーバー 88ファイル / 1171テスト 全通過(8ファイルずつ11バッチ)
  • Web 96ファイル / 882テスト 全通過
  • 変更したファイルはすべて800行未満

マイグレーション

不要。 スキーマ変更なし。追加した SQL は既存カラムを読む SELECT だけ。

🤖 Generated with Claude Code

https://claude.ai/code/session_01SLkuubF8cHzSnnsv4fN9DA

kojira and others added 3 commits September 4, 2026 23:39
イベントを削除しても D1 の行しか消しておらず、表紙画像・イベント写真・
動画(本体+ポスター)・景品画像の R2 オブジェクトが全部孤児になっていた。

後始末の契約を lib/mediaCleanup.ts の1本にまとめ、削除する経路をすべて
「D1 からキーを集める → D1 を消す → R2 をベストエフォートで消す」に揃えた。
キーの組み立て(event-images / event-photos / event-videos+poster)も
ここへ集約している。

失敗方向は孤児に倒す。R2 を先に消すと「参照はあるのに実体が無い」行が
残りうるが、これは行から復元できず、運営の対処の証跡 (#278) も失われる。
実体だけが残る側なら配信はされず、後から prefix を舐めて拾える。
掃除用の列挙は表示用の SELECT を通さない(運営が非表示にした写真を落とすと、
その実体だけが残るため)。収集は D1 削除より前でなければならず、
残る R2 側も1回の multi-delete なので deferBackground には逃がさない。

スキーマ変更は無し(マイグレーション不要)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLkuubF8cHzSnnsv4fN9DA
退会の完全削除は「参加者のいない下書きイベント」の行ごと消すのに、
その R2 の実体(表紙画像・写真・動画・景品画像)を誰も集めていなかった。
#424 が塞いだはずの孤児が退会の経路からそのまま出ていた。

どのイベントが消えるかの条件(WHERE 句)を accountDeletion.ts に1つだけ置き、
DELETE と、削除前にキーを集めるための SELECT の両方から引く。条件を書き写すと
片方だけずれて実体が残るため、同じ文字列を共有する。created_by に渡す値だけが
両者で違う(DELETE は ghost へ付け替えた後、収集は付け替え前なので本人)。
増える D1 の呼び出しはサブリクエスト予算に積み、MIN_COST_PER_USER も上げた。

1000キー刻みの分割も2か所に書かれていた。deleteObjects が消費した
サブリクエスト数を返すようにして、呼び出し側の数え直しをやめる。
失敗しても消費済みなので、途中で落ちても同じ数を返す(予算は必ず積まれる)。

docs/design.md の事実誤りを訂正した。退会時の掃除は venue-images/ と
venue-photos/ を見ておらず、community-icons/ community-banners/ は
そもそも誰も消していない。契約が揃っているのはイベントが持つメディアだけで、
ユーザー・会場の経路はまだ R2 を先に消している(別件 #TBD)。

テストで固定した契約:
- 退会で消える下書きイベントの表紙画像・景品画像が消えること。
  同時に、残る側(公開済み・第三者が参加している下書き)の実体は消さないこと
- 掃除に渡すキー配列に画像なし景品の NULL を混ぜないこと。
  本番の R2 は null キーで multi-delete ごと落ちるが、テスト環境の R2 は
  素通しするため「実体が消えたか」では捕まらない。渡したキー自体を見る
- 表紙画像の単体削除は R2 が落ちても ok を返し、D1 の行は消えること
  (#424 で 500 からベストエフォートに変えた振る舞いを意図として固定する)

スキーマ変更は無し(マイグレーション不要)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLkuubF8cHzSnnsv4fN9DA
■ 収集漏れ(実害あり)
下書きイベントの DELETE は付け替え後の ghost 名義を見ており、しかも
本人のイベントに限定されていない=条件に合う ghost 名義の下書きを
すべて消す。一方で収集側は本人名義しか見ていなかったため、次の3手で
行だけが消えて実体が孤児になった:
  1. A の下書き(第三者 B が参加)は A の退会では消えず ghost 名義になる
  2. B がイベントから抜ける → 参加者ゼロの ghost 名義の下書きになる
  3. 無関係な C が退会すると、その DELETE がこの行を消す

条件の文字列を共有するだけでは足りず、**同じ引数で呼べること**が要る。
所有者の条件を `created_by IN (本人, ghost)` にして、DELETE と収集用の
SELECT を同一の SQL・同一の引数にした。付け替えは status も event_member も
触らないので
  「付け替え後に created_by = ghost」⇔「付け替え前に created_by ∈ {本人, ghost}」
が成り立ち、2つの集合は似ているのではなく同一になる。
「文字列を共有していれば歩調が合う」と書いていた docblock は誤りだったので、
何が集合を等しくしているのかに書き直した。

■ 順序の固定が4経路中2つしか無かった
イベント削除・写真単体削除にしか「R2 を消す時点で D1 の行は消えている」の
テストが無く、表紙画像の単体削除と退会の掃除は順序を逆にしても全部通った。
同じ手法(R2 削除をスタブして、その瞬間の行数を記録する)で4経路すべてを固定。

■ deleteObjects の分割契約が無検査だった
MAX_KEYS_PER_DELETE を 1000 から 5000 にしても通る。テスト環境の R2 は
何個でも受け取るが本番は 1000 超を拒否し、例外は握り潰されるので全部が
静かに孤児になる。`return calls` を `return 0` にしても通り、これは
MIN_COST_PER_USER が守っているサブリクエスト予算を過少に見積もらせる。
R2 に渡した配列の長さと返り値の回数を直接見るテストを追加した。

ミューテーション7件(4経路の順序逆転 / 1000→5000 / return 0 /
収集を本人の下書きだけに戻す)がいずれも赤になることを確認済み。

スキーマ変更は無し(マイグレーション不要)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SLkuubF8cHzSnnsv4fN9DA
@kojira
kojira merged commit 3f3d262 into main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

イベント削除で写真・動画の R2 オブジェクトが残る(動画対応で最大2GB/イベントに増幅)

1 participant