Rebounder Tech Blog

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

契約更新のバッチ処理で、後継行に外部キーを引き継ぐと同日中にステータスが巻き戻る

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

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

結論

placementsの複製行にcreative_idをそのまま引き継いで元行を「終了」にすると、同じcron run内で素材の期限切れを処理するバッチがその外部キーを目印に元行を「申込済」へ書き戻す。

結論

枠(placements)行を後継契約向けに複製するとき、元行のcreative_idを消さずに残したまま元行を「終了」にすると、同じcron run内で走る別のバッチが、そのcreative_idを検索条件の一部として元行に再マッチし、「終了」にした直後の行を「申込済」へ書き戻す。 新しく複製した行ではなく、古い行のほうが書き戻される。

症状

6ヶ月契約の自動更新は、日次cronの中でこの順に処理する。

  1. 満了が近い契約を見つけ、後継契約を新規作成する
  2. その契約が占有していた枠(placements)行を、後継契約向けに複製する
  3. 複製した直後に、元の枠行をavailability: "終了"へ更新する

このとき複製処理は、元行を{ ...p }でスプレッドコピーしてからid / created_at / updated_at / loopだけを削り、contract_idstart_dateend_dateを新しい契約用に書き換える。実装レビューで見つかったのは、この複製でcreative_idだけが素通りしていた点だった。

// placements: 後継契約の行として複製し、元契約の行は「終了」へ。
// creative_id/availability は引き継ぐ=配信を途切れさせない(元行は終了なので expireEndedCreatives の
// creative 巻き戻しは後継行にだけ効く)。
if (placements.length > 0) {
  const rows = placements.map((p) => {
    const copy: Record<string, unknown> = { ...p };
    delete copy.id;
    delete copy.created_at;
    delete copy.updated_at;
    delete copy.loop;
    copy.contract_id = successorId;
    copy.start_date = period.start;
    copy.end_date = period.end;
    return copy;
  });
  // ...insert(rows) のあと
  await admin.from("placements").update({ availability: "終了" }).in("id", oldIds);
}

このコードのコメントには「元行は終了なので、expireEndedCreativesの巻き戻しは後継行にだけ効く」と書かれている。この前提が誤りだった。

原因

同じcron runの中では、契約更新のあとに素材の期限切れを処理する別のバッチ(expireEndedCreatives)が走る。これはvalid_toが過ぎた素材(creatives)を見つけてstatus: "停止"にし、その素材が枠行に紐づいていれば、次の入稿に向けて枠を"申込済"へ開放する。

if (cr.placement_id) {
  await admin
    .from("placements")
    .update({ availability: "申込済", status: "予約", creative_id: null })
    .eq("id", cr.placement_id)
    .eq("creative_id", cr.id);
}

この更新はidcreative_idの両方が一致した1行だけを狙い撃ちする設計になっている。ここで問題になるのは、素材(creatives)側のplacement_id複製で新しく採番されたIDではなく、元の枠行のIDを指したままだということだ。契約更新の処理はcreativesテーブルを一切更新しないので、素材のリンク先は複製の前後で変わらない。

つまり、契約更新が元行を"終了"にしたあとも、元行のidは素材のplacement_idと一致し続け、元行のcreative_idも複製時に消していないので素材のidと一致し続ける。expireEndedCreatives.eq("id", cr.placement_id).eq("creative_id", cr.id)は、複製された新しい行ではなくこの元行にマッチし、複製直後に"終了"へ落としたばかりの行を"申込済"へ書き戻す。

枠の在庫定員はavailabilityが「商談中/申込済/掲載中」のいずれかである行の合計で計算する設計になっている。"終了"のはずの行が同日中に"申込済"として復活するため、この二重消費を防ぐために入れたはずの対策が、標準の運用経路(日次cronの中)で再発する形になっていた。

直し方

修正は、複製した新しい行と元の行の両方creative_idをnullにすることだった。

copy.contract_id = successorId;
copy.creative_id = null; // 素材は元契約期間に紐づく=後継へ dangling 参照を持ち込まない
// ...insert(rows) のあと
await admin
  .from("placements")
  .update({ availability: "終了", creative_id: null })
  .in("id", oldIds);

素材は元の契約期間に対して入稿・承認されたものであり、後継契約の枠にそのまま引き継ぐべきものではない。複製した新しい行のcreative_idをnullにすることで、後継契約へダングリングした参照を持ち込まなくなる。そして元行のcreative_idもnullにすることで、expireEndedCreatives.eq("creative_id", cr.id)が一致しなくなり、元行が書き戻されることもなくなる。素材そのものの継続入稿は、既存の期限切れリマインドから再入稿する経路に任せる形で、自動延長はしない。

再発防止

同じテーブルを別々のタイミングで更新するバッチが複数あるとき、「片方が完了させた行を、もう片方が検索条件で再び拾わないか」は、availabilityのような状態フィールドだけを見ていては分からない。今回のケースでは、状態を"終了"に変えたこと自体は正しかったが、.eq("creative_id", cr.id)という別バッチの検索条件が見ている外部キーを、複製時に断ち切っていなかったことが原因だった。行の状態を変えるときは、その行を検索条件に使っている他のバッチが、状態以外のどのカラムを条件にしているかも合わせて確認する必要がある。

よくある質問

Q1なぜ同じ日のうちにステータスが元に戻ったのですか?

契約更新のバッチが枠(placements)の複製行を作るとき、元行のcreative_idを消さずに残していたためです。同じcron runの中で走る、素材の期限切れを処理する別のバッチが、そのcreative_idを検索条件の一部として元行に再マッチし、「終了」に設定した直後の行を「申込済」へ上書きしていました。

Q2なぜ複製した新しい行ではなく、古い行のほうが書き戻されたのですか?

素材(creatives)側が持つplacement_idは、複製で新しく採番されたIDではなく、元の枠行のIDを指したままだったからです。期限切れバッチは素材のplacement_idを起点に該当する枠行を検索するため、新しい行ではなく古い行のほうが対象になっていました。

Q3修正はどう直しましたか?

枠行を複製するとき、複製した新しい行と元の行の両方でcreative_idをnullにしました。素材は元の契約期間に紐づくものであり、後継契約へダングリングした参照を持ち込む必要が無いためです。creative_idがnullになれば期限切れバッチの検索条件に一致しなくなり、元行は「終了」のまま残ります。

Q4同じ種類の事故を防ぐには何を確認すればよいですか?

同じテーブル(この場合はplacements)を複数のバッチが別々のタイミングで更新する設計では、片方のバッチが残した外部キーが、もう片方のバッチの検索条件に偶然一致しないかを確認する必要があります。「後から見れば終了しているから安全」という判断は、検索条件が実際に何を見ているかを追わない限り成立しません。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2
  • 2026-07-12 の実装レビュー(PR-R3)で発覚・同日中に修正

この記事の根拠

  • TypeScriptファイル 236〜335行目コミット f4141fd
  • TypeScriptファイル 253〜290行目コミット 99bc3c9
  • TypeScriptファイル 957〜994行目コミット f4141fd

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