家計簿アプリを作る #91:削除時にレシート画像もR2から消す

家計簿アプリ作成シリーズの第91回です。前回、外部キー制約でアカウント削除時の関連データがまとめて消えるようにしましたが、カスケードで消えるのはD1のレコードだけで、R2に保存したレシート画像は残ったままでした。今回はそこに対応します。

調べてみたら、漏れはもっと広かった

RECEIPTS_BUCKETを使っている箇所を検索したところ、取引の登録、取引の編集、画像を返すAPIの3箇所だけでした。つまり一覧画面の取引の削除と一括削除も、R2には一切触れていません。レシート付きの取引を削除すると、画像ファイルだけがR2に残り続けていたことになります。

アカウント削除だけの問題だと思っていましたが、DBから取引が消えるすべての経路に同じ漏れがありました。今回は3箇所すべてに対応します。

キーは削除する前に集める

レシートのキーはreceipts/${crypto.randomUUID()}という形で、ユーザーIDを含んでいません。そのためR2側から「このユーザーの画像」を探すことはできず、D1のreceipt_keyを見るしかありません。

そしてDBの行を消すとreceipt_keyも消えます。特にアカウント削除は、前回入れたカスケードによってuserを消した瞬間に取引が全部消えるので、消す前にキーを取得しておく必要があります。

const receipts = await db
  .select({ key: transactions.receiptKey })
  .from(transactions)
  .where(and(eq(transactions.userId, locals.user!.id), ne(transactions.receiptKey, '')))
  .all();

R2とDB、どちらを先に消すか

D1とR2は別々のサービスなので、両方をまとめてアトミックに消すことはできません。どちらかが成功し、もう片方が失敗する可能性があります。

R2を先に消す方針にしました。理由はこうです。

  • R2の削除が成功してDBの削除が失敗した場合:取引の行が残り、そこから参照している画像だけが無い状態になります。一覧にはレシートありの表示が残り、画像のリンクは切れますが、もう一度削除を実行すれば解消できます
  • DBを先に消してR2の削除が失敗した場合:キーが分からなくなるので、その画像は誰にも見つけられないままR2に残り続けます

再試行が成り立つのは、R2のdeleteが存在しないキーを指定してもエラーにならないからです。やり直したとき、R2の削除は何もせずに成功し、DBの削除だけが改めて実行されます。

お題87でアカウント削除の順番を「失敗したときに再試行できる側に倒れる順番」で決めましたが、同じ考え方がそのまま使えました。

try/catchは入れなかった

最初、「例外処理で囲まないとまずいのでは」と考えました。結論としては、データの整合性という意味では不要でした。

awaitした処理が失敗すると例外が投げられ、その時点で後続の処理は実行されません。つまり「R2の削除が失敗したらDBを消さない」という順番の保証は、例外がそのまま上に伝わること自体が担っています。

try/catchを入れる意味はユーザー体験の方で、素っ気ない500エラー画面ではなく、画面上にエラーメッセージを出したい場合に使います。今回は入れない方針にしましたが、入れる場合は3点の注意が必要です。

  1. R2の削除とDBの削除を同じtryに入れ、catchしたら必ずreturnで止める。R2の削除だけを囲んで処理を続けると、失敗してもDBが消えてしまい、避けたかった状態そのものになります
  2. redirect()はtryの外に出す。SvelteKitのredirect()は内部的に例外を投げるので、tryの中に入れると正常なリダイレクトまでcatchに捕まります
  3. 返したエラーが画面に表示されるか確認する

.get()と.all():1件では通ってしまうテスト

一括削除で1つバグを出しました。

const receipts = await db.select({ key: transactions.receiptKey }).from(transactions)
  .where(...)
  .get();   // ← 最初の1行しか返さない

.get()は条件に一致した行のうち先頭の1行だけを返します。単体削除では対象が1件なので正しく動きますが、一括削除では「レシート付きの取引を複数選んでも、R2から消えるのは1件だけ」になります。

変数名をreceiptsと複数形にしていたのに.get()を呼んでいた、という食い違いでした。複数行を取得する.all()に変え、キーの配列をまとめて渡す形にしました。

const receipts = await db.select({ key: transactions.receiptKey }).from(transactions)
  .where(...)
  .all();

if (receipts.length > 0) {
  await platform!.env.RECEIPTS_BUCKET.delete(receipts.map((r) => r.key));
}

if (receipts)は配列が空でも真になるので、receipts.length > 0での判定にしています。

このとき最初のテストは「OK」だと思っていたのですが、レシート付きの取引を1件だけ含めて試していたための勘違いでした。1件なら正しく動くが複数だと壊れるバグは、テストの作り方次第で簡単に見逃せます。複数件で試して初めて気付けました。

1000件の上限

R2のdeleteは1回あたり最大1000件です。一括削除は画面に表示されている分しか選べないので超えませんが、アカウント削除は「そのユーザーの全レシート」が対象なので、長く使っていれば超える可能性があります。配列を1000件ずつに区切って呼ぶようにしました。

for (let i = 0; i < receipts.length; i += 1000) {
  await platform!.env.RECEIPTS_BUCKET.delete(receipts.slice(i, i + 1000).map((r) => r.key));
}

sliceは終端が配列の長さを超えていてもそこで止まってくれるので、最後の端数を特別扱いする必要がありません。レシートが0件ならforの中身が一度も実行されないので、空配列でdeleteを呼ぶこともありません。

userIdの条件はセキュリティの境界でもある

キーを取得するSELECTには、必ずuserIdの条件を入れています。

eq(transactions.userId, locals.user!.id)

これが無いと、他人の取引IDをフォームに入れて送信した場合、DBの削除はuserIdの条件で弾かれても、その前に実行されるR2の削除で他人のレシート画像が消せてしまいます。R2側にはファイルの持ち主の情報がないので、「このユーザーのレシートか」の判断はD1で行うしかありません。

残っている課題

今回の調査で、関連する問題をもう2つ見つけました。どちらも別の機会に対応する予定です。

  • リストアでもR2に孤児ファイルが残る:restoreBackupは取引を全削除して入れ替えますが、R2には触れていません。また、バックアップのJSONにはtransactionTemplatesとbudgetAlertsが含まれているのに、復元時は対象外のままです
  • レシート画像のAPIに所有者チェックが無い:/api/receipts/[...key]は、URLで渡されたキーをそのままR2に問い合わせて返しています。ログイン済みであれば、他人のレシート画像でもキーさえ分かれば取得できる状態です。キーはUUIDなので推測は困難ですが、確認は入れておきたいところです

まとめ

ポイント 内容
漏れの範囲を確認する バインディングの使用箇所を検索したら、想定より広く漏れていた
キーの取得タイミング DBを消すとキーも消える。特にカスケードがある場合は先に集めておく
削除の順番 失敗したときに再試行できる側に倒す。R2の削除は冪等なのでやり直せる
try/catchの要否 整合性は例外が後続を止めることで保たれる。入れるのはUXのため
.get()と.all() .get()は先頭1行だけ。複数件のテストをしないと気付けない
R2の一括削除 1回あたり最大1000件。sliceで区切って複数回呼ぶ
所有者の確認 R2は持ち主を知らないので、D1側でuserIdを条件に含めて確認する
← トップページに戻る