Rebounder Tech Blog

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

契約更新のたびに枠を複製し、旧行を終了し忘れると在庫定員が二重に消費される

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

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

結論

占有中の枠を複製して後継契約の行を作る設計では、元の行を終了状態にしない限り、残り枠の集計は複製後も両方の行を数え続け、更新のたびに実体の無い定員消費が積み上がる。

結論

占有中の在庫行を複製して後継契約の行を作る設計では、元の行を「終了」状態にしない限り、残り枠の集計は複製後も新旧の行を両方カウントし続ける。 契約が自動更新されるたびに実体の無い定員消費が積み上がり、いずれ「枠は空いているはずなのに予約できない」という形で表に出る。

何が実装されていたか

サブスクリプション型の広告枠(1つの「ループ」に定員があり、複数社が占有できる仕組み)を自動更新する日次バッチを実装したコミットでは、契約が満了するたびに次の期間の後継契約を新規作成し、占有中のplacements(枠を占有している行)を後継契約の行として複製していた。

// placements を後継契約の行として複製(配信を途切れさせない)。id/監査列は新規に採番。
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; // join の埋め込みを除去
    copy.contract_id = successorId;
    copy.start_date = period.start;
    copy.end_date = period.end;
    if (copy.start_month != null) {
      copy.start_month = period.start.slice(0, 7);
    }
    return copy;
  });
  const { error: plErr } = await admin.from("placements").insert(rows);
  if (plErr) {
    // 在庫行の複製失敗は配信断リスク=赤で可視化(契約自体は成立しているので続行)。
    await notifySlack(
      `🔴 自動更新: placements 複製に失敗(契約 ${successorId})→手動確認 ${slackLink("/admin/loops", "枠を開く")}`
    );
  }
}

複製に成功した後、元の行のステータスを変える処理は無い。元契約自体は直後にcompletedへ更新されるが、それはcontractsテーブルの話であって、placements(枠占有)のほうは新しい行が増えるだけで、古い行はそのまま残る。

原因

このループの残り枠は、placementsのうちavailabilityが「商談中」「申込済」「掲載中」のいずれかの行を合計して求める。実際に枠を確保するRPCの中身は次のとおり。

select coalesce(sum(units), 0) into v_used
  from public.placements
  where loop_id = p_loop_id
    and availability in ('商談中', '申込済', '掲載中');
if v_loop.capacity - v_used < p_units then
  return null;
end if;

複製で作られる後継行は、元の行のavailabilityをそのまま引き継ぐ。つまり元契約の枠が「掲載中」であれば、後継行も「掲載中」でinsertされる。このとき元の行を「終了」などcapacity集計の対象外の状態に変えていなければ、sum(units)は元の行と後継の行の両方を数える。

1回の更新なら定員1枠分の余分な消費で済むが、この契約は6ヶ月ごとに自動更新される設計であるため、更新を重ねるたびに「終了しないまま残った過去の行」が積み上がり、実体の無い枠消費がその都度加算されていく。ループのcapacityは変わらないので、更新回数が増えるほど実際に確保できる残り枠は縮んでいく。

直す

複製が成功したときに限り、複製元の行を「終了」へ更新する。複製が失敗した場合は元の行を残す(配信を止めない安全側の分岐)。

const { error: plErr } = await admin.from("placements").insert(rows);
if (plErr) {
  // 在庫行の複製失敗は配信断リスク=赤で可視化(契約自体は成立しているので続行)。
  // 元行は「終了」にしない(複製が無いのに止めると配信が即断する=安全側は現状維持)。
  await notifySlack(
    `🔴 自動更新: placements 複製に失敗(契約 ${successorId})→手動確認 ${slackLink("/admin/loops", "枠を開く")}`
  );
} else {
  const oldIds = placements
    .map((p) => p.id as string | undefined)
    .filter((v): v is string => !!v);
  if (oldIds.length > 0) {
    await admin
      .from("placements")
      .update({ availability: "終了" })
      .in("id", oldIds);
  }
}

複製と終了更新を同じ条件分岐の中に置き、「複製が成功したときだけ元行を終了させる」を1箇所に閉じている。複製に失敗した状態で元行だけ終了させてしまうと、後継行が無いのに枠だけ失うことになり、配信がその場で止まる。この直し方は成功した場合と失敗した場合の両方で「二重にカウントされる行」と「配信が途切れる行」のどちらか一方しか起こさないようにしている。

再発しない形にする

この記事の根拠にあるのは、この修正が実装コミットと同日中のコードレビューで指摘され反映されたという事実までで、修正後にどのような監視や定期検証を追加したかまでは含まれていない。言えるのは構造的なことだけである。

「占有中の行を複製して権利を引き継ぐ」設計を採るなら、複製の成功と元行のステータス変更は同一トランザクション的な単位として扱う必要がある。 複製だけ実装して元行の後始末を別工程・別PRに任せると、集計ロジック側は「複製された行も元の行も、どちらも同じavailabilityの値を持つただの行」としてしか区別できないため、後始末が漏れた分だけ静かに数え間違え続ける。

よくある質問

Q1なぜ元の行を消さずに複製するのですか?

配信を途切れさせないためです。占有行を先に削除してから新しい行を作ると、その間だけ枠が空いて別の申込に取られたり、素材の表示が止まったりします。複製してから元の行を終了させる順番にすることで、配信を止めずに枠の権利だけを引き継げます。

Q2この事故は実際に本番で定員が溢れましたか?

根拠にあるのは、機能を実装したのと同じ日のコードレビューで指摘され、修正コミットが同日中に入った、という事実だけです。本番で実際に定員超過の申込が発生したかどうかは sources に含まれていません。

Q3複製そのものが失敗したときはどうなりますか?

元の行は終了扱いにしません。複製に失敗した状態で元の枠だけ終了させると配信が即座に止まってしまうため、安全側として現状維持を選び、Slackで手動確認を促す通知だけ送ります。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js ^2.106.2
  • 2026-07-12 の実装コミットと同日中の修正コミット(本番デプロイ前のコードレビューで発見)

この記事の根拠

  • TypeScriptファイル 157〜180行目コミット f917f3b
  • TypeScriptファイル 253〜290行目コミット 99bc3c9
  • SQLファイル 131〜136行目コミット 34e395c

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