イベント削除で写真と動画の実体も消す (#424) - #484
Merged
Merged
Conversation
イベントを削除しても 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #424
イベントを削除しても R2 のオブジェクトが残り、動画 (#408) で1イベント最大2GB の孤児が出うる状態を直す。ついでに、同じことをしている経路をひとつの契約に寄せた。
棚卸し(メディアを消しうる経路すべて)
routes/eventCrud.tsroutes/eventPhotos.tsroutes/images.tsroutes/eventMeetPrizes.tsdeleteObjectsに統一lib/purgeDeleted.tscollectEventObjectsを通すroutes/adminModeration.tsコミュニティ削除と、
routes/bgm.ts/routes/venues.tsの R2 先行削除は範囲外として #483 に切り出した。過去の削除で既に残っている孤児の掃除は #482。選んだ失敗方向: 孤児に倒す
D1 の書き込みと R2 の複数削除は原子的にできない。どちらを先にしても途中で落ちうるので、どう壊れるかを選ぶ話になる。
よって契約は「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 名義の下書きをすべて消す。
再現する筋書き:
event-images/{id}が R2 に残る。条件文字列を共有するだけでは足りず、同じ引数で呼べることが要る。所有者の条件を
created_by IN (本人, ghost)にして、SELECT と DELETE を同じ SQL・同じ引数[userId, ghostId, userId]で呼ぶようにした。付け替えはstatusもevent_memberも触らないので「付け替え後にcreated_by = ghost」⇔「付け替え前にcreated_by ∈ {本人, ghost}」が成り立ち、2つの集合は似ているのではなく同一になる。変異テスト(使い捨てコピーで実施、各変異は shasum で適用を確認)
collectEventObjectsから景品画像のキーを落とすcollectEventObjectsから表紙画像のキーを落とすphotoObjectKeysからポスターのキーを落とすevent-videosの削除テストを含む)listIdsByEventが運営非表示の行を除外するlistImageKeysByEventからimage_key IS NOT NULLを外すdeleteObjectsを no-op にするMAX_KEYS_PER_DELETEを 1000 → 5000deleteObjectsのreturn callsをreturn 0に順序の変異は R2 が正常なら結果が同じで、
deleteObjectsが例外を飲むため「R2 が落ちる」テストでも通ってしまう。順序そのものを見るテスト(stub したdeleteの中で D1 の行数を数える)を4経路すべてに置いて捕まえている。分割の契約も、テスト用バケットが1000キー超を受け付けてしまうため件数を直接見ている。新規テストは修正前のツリーでは全部落ちることを確認済み。検証
pnpm -r typecheckクリーンマイグレーション
不要。 スキーマ変更なし。追加した SQL は既存カラムを読む SELECT だけ。
🤖 Generated with Claude Code
https://claude.ai/code/session_01SLkuubF8cHzSnnsv4fN9DA