Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

広告の差し替え承認で、valid_fromが未来の予約済み素材まで退役させていた

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

deliverCreativeToPlacementは同じ枠(placement_id)の承認済み/配信中の素材を無条件で退役させていたため、valid_fromが未来の有償予約済み素材まで一緒に停止扱いになっていた。

結論

同じ枠(placement_id)に新素材を配信する直前、その枠にある「承認」または「配信中」の他素材を無条件で退役(status: 停止)させる処理には、valid_from(掲載開始日)の判定が無かった。 そのため、まだ開始日が来ていない有償予約済みの素材まで、新素材の承認と同時に退役扱いになっていた。

想定されていた事故

広告枠は「枠1:素材1」で配信する設計になっている。新しい素材を承認すると、deliverCreativeToPlacementが、同じ枠に残っている旧素材を先に退役させてから、新素材を配信キューに載せる。

// 1. 同枠の他素材を退役(停止)— 配信は常に枠1:素材1
await admin
  .from("creatives")
  .update({ status: "停止" })
  .eq("placement_id", opts.placementId)
  .neq("id", opts.creativeId)
  .in("status", ["承認", "配信中"]);

この条件が見ているのはplacement_idとstatusだけで、valid_from(その素材の掲載開始日)はどこにも入っていない。つまり、同じ枠に「今日から配信中の素材」と「来月から始まる予約済みの素材」が両方あるとき、今日の承認操作は両方を無条件で退役の対象にしてしまう。

広告枠は先に申込金を受け取って予約を確保する運用のため、valid_fromが未来の素材はまだ配信されていないが、既に有償で確保された予約になる。この条件のまま稼働していれば、別の枠の差し替え承認が走るたびに、無関係な未来予約の素材が理由も通知もなく停止扱いになっていたはずだった。

原因

このコードはplacement_idが一致する素材を「今その枠を占有しているもの」として扱っていた。実際には、同じ枠に対して複数の素材が時間的に重ならずに並んでいること自体は正常な状態で、placement_idの一致だけでは「現在表示されているかどうか」を判定できない。

「現在表示対象かどうか」を決めるのはvalid_fromであり、これを見ないままstatusだけで退役させる設計だったため、承認という単発の操作が、無関係な将来の予約まで巻き込む形になっていた。

直し方

修正は、退役対象の条件にvalid_fromの判定を追加し、「現在表示対象」の素材だけに限定することだった。

const { data: superseded, error: supErr } = await admin
  .from("creatives")
  .update({ status: "停止" })
  .eq("placement_id", opts.placementId)
  .neq("id", opts.creativeId)
  .in("status", ["承認", "配信中"])
  .or(`valid_from.is.null,valid_from.lte.${today}`)
  .select("id, title");
if (supErr) {
  // 退役に失敗したまま配信すると flip-flop 防止(B-1)が静かに失われるため中断(fail-closed)。
  return { ok: false, error: `supersede failed: ${supErr.message}` };
}

valid_fromがnull、または今日以前の素材だけを退役対象にすることで、未来開始の予約済み素材は対象から外れる。その素材は開始日が来たときに別の処理(deliverScheduledStarts)が配信し、その時点であらためて現行素材を退役させる。

同時に2点を足している。1つは、退役のUPDATE自体がエラーを返したときの扱いで、以前は結果を見ずに次の配信処理へ進んでいた。修正後はエラーが出たら新素材の配信そのものを中断するfail-closedにした。退役に失敗したまま配信を進めると、同じ枠に素材が2つ残り、「枠1:素材1」を前提にした既存の仕組みが静かに壊れるためだ。もう1つは、退役した素材ごとにSlack通知と監査ログ(logAuditSystem)を残すようにした点で、修正前は誰にも結果が見えないまま処理が完了していた。

再発防止

この条件は「実装レビュー(再レビュー指摘N-1)」で発覚し、本番で素材を運用し始める前に直っている。見つかった理由は、レビューで.eq("placement_id", opts.placementId)という条件が実際に何を絞り込んでいるかを読み直し、「同じ枠にあるもの」と「今表示されているもの」が同じではないことに気づけたことだった。

状態を書き換える処理を見るときは、その更新条件が対象を時間軸で絞り込んでいるかどうかを確認する必要がある。placement_idやstatusのような「今の構造」を見る条件だけでは、「予約が未来から始まる」という時間差のあるデータを正しく除外できない。

よくある質問

Q1なぜ未来開始の予約素材まで退役してしまったのですか?

新素材を配信する直前に、同じ枠(placement_id)で status が「承認」または「配信中」の他素材を無条件でstatus:停止に更新していたためです。この更新条件にはvalid_from(掲載開始日)の判定が無く、まだ開始日が来ていない有償予約済みの素材も対象に含まれていました。

Q2この不具合は本番の配信に出ましたか?

出ていません。この処理は2026-06-12の実装レビュー(再レビュー指摘N-1)で発覚し、同日中に修正されています。発覚した時点では該当コードはまだ本番で使われていませんでした。

Q3直し方はどう変えたのですか?

退役対象の条件に.or(valid_from.is.null,valid_from.lte.今日)を追加し、「現在表示対象」の素材だけに限定しました。未来開始の素材は対象から外れ、開始日になったら別の処理(deliverScheduledStarts)がその時点の素材を配信し、そこで現行素材を退役させます。

Q4退役のUPDATEが失敗したらどうなりますか?

修正後はfail-closedにしています。退役のUPDATEがエラーを返した場合、新素材の承認・配信そのものを中断してエラーを返すようにしました。退役に失敗したまま配信を進めると、同じ枠に2つの素材が残り、もう一つの対策(枠1:素材1の強制)が静かに壊れるためです。

Q5退役した素材はどこで分かりますか?

退役ごとにSlack通知と監査ログ(logAuditSystem)を記録するようにしました。修正前は退役の実行結果を誰にも通知していませんでした。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2 — kimiteras-portal
  • 2026-06-12 の実装レビュー(再レビュー指摘N-1)で発覚・同日中に修正

この記事の根拠

  • TypeScriptファイル 231〜237行目コミット eec719b
  • TypeScriptファイル 233〜262行目コミット 43c30c2

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。