Rebounder Tech Blog

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

upsertでtarget_schools列ごと上書きすると複数placementで値が交互にフリップする

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

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

結論

契約レベルの列にplacementレベルの部分集合をそのまま上書きすると、複数のplacementを持つ契約だけ対象校がA⇄Bと交互にフリップし、効果レポートの対象校数が常にどちらか片方欠ける。

結論から

契約(contract)レベルの列に、配信枠(placement)レベルの値をそのまま書くと、1契約に複数の配信枠があるときだけ値が交互にフリップする。 外部の配信APIが送ってくる対象校の集合は「今回配信した配信枠の対象校」でしかなく契約全体の対象校ではないのに、それを契約のtarget_schools列へupsertでそのまま上書きしていた。複数の配信枠を持つ契約だけが、どちらかが再配信されるたびに別の配信枠の対象校で上書きされ、[A校]⇄[B校]と入れ替わっていた。

症状

1契約に複数の配信枠(A校枠+B校枠)がある広告主で、効果レポートの対象校数(advertiser-metricsのtargetSchoolCount)が常にどちらか片方欠けていた。原因が見つかる前のcontractsへのupsertは次のようになっていた。

// partner-delivery.ts(修正前)
await tx
  .insert(contracts)
  .values({
    portalContractId: c.portalContractId,
    advertiserId,
    status: contractStatusValue,
    startedAt: c.startedAt ?? new Date(),
    endedAt: c.endedAt,
    monthlyFeeJpy: c.monthlyFeeJpy ?? 0,
    targetSchools: c.targetV2SchoolIds,
    createdBy: null,
    updatedBy: null,
  })
  .onConflictDoUpdate({
    target: contracts.portalContractId,
    set: {
      advertiserId,
      status: contractStatusValue,
      startedAt: c.startedAt ?? new Date(),
      endedAt: c.endedAt,
      monthlyFeeJpy: c.monthlyFeeJpy ?? 0,
      targetSchools: c.targetV2SchoolIds, // ← ここが毎回の上書き
      updatedAt: new Date(),
      updatedBy: null,
    },
  });

c.targetV2SchoolIdsは、外部APIから届いた今回のリクエストが運んできた値そのものだ。A校枠の配信が届けば[A校]、その後B校枠が再配信されれば[B校]が、同じcontracts行のtarget_schoolsへそのまま書き込まれる。1配信枠しかない契約では問題にならないが、複数配信枠を持つ契約だけが、直近に処理された配信枠の対象校だけを残し続けていた。

原因

契約(contracts)は1行、配信枠(ads)は複数行という親子関係なのに、親の列に子1件分の値をそのまま書いていた。レイヤ違いである。target_schoolsは本来「この契約でこれまでに配信対象になった校の集合」を表すべき列だが、実装は「このリクエストで届いた配信枠の対象校」という部分集合をそのまま上書きする形になっていた。1契約に配信枠が1つしかない間は部分集合=全体集合なので症状が出ず、複数配信枠を持つ契約が現れて初めて表面化する。

直し方

既存値との和集合で「これまでに配信対象になった校の集合」を維持する方式に変えた。最初の実装案はSELECTで既存値を読み、JS側で配列をマージしてからupsertする形だったが、レビューで原子性の指摘が入った。配信は「承認直後の即時キック」と「日次cron」という2つの経路から並行して来るため、SELECTした直後にもう一方の経路が先に書き込んでしまうと、マージ結果が古い値を基準にしたlost updateになり、修正したはずの上書き症状が低頻度で再発する。そのためSELECTの段階を無くし、ON CONFLICTのDO UPDATE SET内でSQL側のjsonb併合として和集合をその場で計算する形に原子化した。

// partner-delivery.ts(修正後)
// target_schools は**和集合で保つ**。portal の targetV2SchoolIds は
// 「今回配信した placement の対象校」であり契約全体の対象校ではない。毎回上書きすると、
// 1 契約に複数 placement(A校枠+B校枠)がある広告主で、どちらかの再配信のたびに
// [A]⇄[B] とフリップし、K1 効果レポートの対象校数(advertiser-metrics の
// targetSchoolCount)が常にどちらか片方欠ける。契約レベルの列に placement レベルの値を
// 書くレイヤ違いなので、既存値と併合して「これまでに配信対象になった校の集合」を維持する。
// ⚠ 併合は ON CONFLICT の SET 内で SQL 側が行う(原子性)。
//   select→TS 併合→upsert だと、portal 配送の 2 入口(承認直後の即時キック+日次 cron)
//   の並行実行で lost update が起き、修正した症状が低頻度で再発する。
//   縮小(契約から校を外す)は反映されない累積集合=ads の掃除とは非対称
//   (表示・KPI 用 Read Model のため許容)。
const mergedTargetSchoolsSql = sql`(
  select coalesce(jsonb_agg(distinct t.value), '[]'::jsonb)
  from jsonb_array_elements_text(
    coalesce(
      case when jsonb_typeof(${contracts.targetSchools}) = 'array' then ${contracts.targetSchools} else '[]'::jsonb end,
      '[]'::jsonb
    ) || excluded.target_schools
  ) as t(value)
)`;

setの中でtargetSchools: c.targetV2SchoolIdsだった行をtargetSchools: mergedTargetSchoolsSqlに差し替えるだけで、以降の挙動は「既存のtarget_schools(コンフリクト前の行)」と「今回excludedとして渡ってきた値」をjsonb_array_elements_textで展開しjsonb_agg(distinct ...)で重複無く束ね直す、という一度のSQL文で完結する。JS側でのマージも追加のSELECTも不要になった。

テストは「別のplacementが交互に届いても和集合が保たれる」ことと、「同じ集合の再送では重複しない」ことの両方を確認している。

// partner-delivery.test.ts
it("target_schools は再送で**和集合**を保つ(placement 単位の配信で相互上書き=フリップしない)", async () => {
  const first = baseInput(); // targetV2SchoolIds: [schoolA]
  await deliver(first);

  const second = baseInput();
  second.ads[0].portalPlacementId = "cccccccc-cccc-4ccc-8ccc-cccccccccc03";
  second.ads[0].v2SchoolId = fx.schoolB;
  second.contract!.targetV2SchoolIds = [fx.schoolB];
  await deliver(second);

  const [c] = await sql<{ target_schools: string[] }[]>`SELECT target_schools FROM contracts`;
  expect([...c.target_schools].sort()).toEqual([fx.schoolA, fx.schoolB].sort());

  // 同じ集合の再送でも重複しない(冪等・distinct)。
  await deliver(second);
  const [c2] = await sql<{ target_schools: string[] }[]>`SELECT target_schools FROM contracts`;
  expect(c2.target_schools).toHaveLength(2);
});

再発防止

和集合挙動を固定するRLS回帰テストを追加し、誰かが将来「シンプルな上書き」に戻そうとしてもこのテストがすぐ落ちるようにした。あわせてAPIの契約文書にも「target_schoolsは累積和集合であり、縮小(契約から校を外す)はこの値には反映されない」という非対称性を明記している。表示・KPI用のRead Modelとしては累積和集合のままで問題ないが、配信の実体(どの校に今広告が出ているか)の掃除は別ロジック(ads側)が担うという役割分担も、同じ文書に書き残した。

よくある質問

Q1なぜ対象校が交互にフリップしたのですか?

配信APIが送ってくる値は『今回配信したplacementの対象校』だけで契約全体の対象校ではないのに、それを契約レベルのtarget_schools列へそのまま上書きしていた。1契約に複数placement(A校枠+B校枠)があると、どちらかが再配信されるたびに別のplacementの対象校で上書きされ、A⇄Bと交互に入れ替わっていた。

Q2この問題はどう直しましたか?

単純な上書きを、既存値との和集合(これまでに配信対象になった校の集合)を維持する方式に変えた。縮小(契約から校を外す)はこの累積集合には反映されない非対称設計にしている(表示・KPI用のRead Modelのため許容)。

Q3和集合をSELECT→マージ→upsertで実装すると何が問題になりますか?

承認直後の即時キックと日次cronという2つの書き込み経路が並行で動くため、SELECT後にもう一方が先に書き込むとlost updateが起きて、修正したはずの症状が低頻度で再発する。ON CONFLICTのDO UPDATE SET内でSQL側のjsonb_array_elements_text+jsonb_aggとして和集合をその場で計算し、SELECT段階を無くして原子化した。

確認した環境

  • drizzle-orm ^0.45.2 / postgres(postgres.js) ^3.4.5(packages/db)
  • 2026-07-25 にportal横断監査で発見、同日のcommit(cda60197)で修正

この記事の根拠

  • TypeScriptファイル 290〜335行目コミット cda6019
  • TypeScriptファイル 223〜244行目コミット cda6019

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