Cron を使わない古いデータの削除

保持期間(既定は 180 日)を過ぎた問い合わせを、定期実行のジョブなしで削除するしくみを説明します。

問い合わせは保持期間(既定は 180 日)を過ぎると削除されます。定期実行のジョブは使わず、ふだんのリクエストの中で、多くても 1 時間に 1 回削除します。

何をするか

  • 新しい問い合わせの後に削除します。 問い合わせを保存した後、Worker は ctx.waitUntil(応答の後も処理を続けるしくみ)の中で settings.last_cleanup_at を確認します。前回から 1 時間以上たっていれば、保持期間を過ぎた問い合わせを削除し、last_cleanup_at を更新します。
  • 管理画面とログインのときも削除します。 問い合わせの一覧を開いたときと、Passkey でログインしようとしたときにも、同じ削除の処理が動きます。新しい問い合わせが来なくても、古いものは消えます。リクエストがまったく来ないサイトでは、次のリクエストまで何も削除されません。
  • 削除は速く済みます。 created_at にインデックスがあるからです。
  • 期限切れのチャレンジも消します。 Passkey でログインしようとするたびに、Better Auth の verification テーブルにチャレンジを書き込みます。使ったチャレンジはすぐ消えますが、途中でやめたものは残り続けます。同じ削除の処理で、期限切れの行も消します。チャレンジは数分で期限が切れ、ログインには回数制限があるので、インデックスがなくてもテーブルは小さいままです。
  • 期限切れのセッションも消します。 期限切れの管理者のセッションは、ブラウザがそのトークンをもう一度送るまで、トークンとブラウザ名を session テーブルに残します。送られないままのこともあります。同じ削除の処理で、期限を過ぎたセッションも消します。セッションを持つのは管理者だけなので、このテーブルも小さいままです。

この自動の削除とは別に、管理者は問い合わせを手動で削除したり、CSV で書き出したりもできます。書き出しは 1 回のクエリで 200 行ずつ読んで順に送り出すので、テーブルが大きくても同じように動きます。

コードでは

問い合わせ API は、応答を返す直前に削除の処理を始めます。runInBackground() は処理を ctx.waitUntil に渡します。失敗してもログに書くだけで、応答は変えません。

packages/server/src/features/contact/contact-routes.ts
runInBackground(c, 'prune', () => pruneIfDue(env.DB, now));
return c.json({ ok: true });

pruneIfDue() は、まず削除を実行する権利を取ります。条件付きの upsert(行がなければ追加し、あれば更新)を使い、保存されている last_cleanup_at が 1 時間以上前のときだけ現在の時刻に書き換えます。書き換えたリクエストだけが削除に進むので、2 つのリクエストが同時に来ても、削除するのは片方だけです。

保持期間は settings.retention_days から読みます。範囲は 1〜3,650 日で、ないときや不正な値のときは 180 日です。

packages/server/src/features/retention/prune.ts
const orm = getDatabase(db);
const claim = await orm
  .insert(settings)
  .values({ key: 'last_cleanup_at', value: String(now) })
  .onConflictDoUpdate({
    target: settings.key,
    set: { value: sql`excluded.value` },
    setWhere: lte(
      sql`cast(${settings.value} as integer)`,
      now - PRUNE_INTERVAL_MS,
    ),
  });
if (claim.meta.changes === 0) return { ran: false };
const retentionDays = await getRetentionDays(db);
const result = await orm
  .delete(submissions)
  .where(lt(submissions.createdAt, now - retentionDays * DAY_MS));
const verifications = await orm
  .delete(verification)
  .where(lt(verification.expiresAt, new Date(now)));
const sessions = await orm
  .delete(session)
  .where(lt(session.expiresAt, new Date(now)));

なぜこう作るか

goodnight-web は Cron Triggers を使いません。設定して理解するものを、Worker 1 つとデータベース 1 つだけにするためです。

古いデータは決まった時刻に消す必要はなく、いずれ消えれば十分です。そのため、もともと来るリクエストの中で削除します。1 時間に 1 回までに間引くので、増える処理はわずかです。

削除は応答の後に動くので、問い合わせの送信を遅くしたり、失敗させたりすることはありません。

削除した行も、しばらくは D1 の Time Travel で復元できます。プライバシーについてを参照してください。

目次