Rebounder Tech Blog

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

同一フィードのURL重複でON CONFLICT DO UPDATEが21000で全断した

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

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

結論

同一フィード内に同じURLの記事が2件あると、1本のINSERT ... ON CONFLICT (source, url) DO UPDATEはPostgresの21000エラー「cannot affect row a second time」で失敗し、fail-softなcatchがそのフィード全体を無音でskipする。

結論

1本のINSERT文でまとめてupsertしていると、同じconflict targetの行が2件混じっただけで文全体が失敗します。 ニュース記事を取得してDBへ保存するジョブが、1フィード分の記事を1本のINSERT ... ON CONFLICT (source, url) DO UPDATEで発行していたところ、同一フィード内に同じURLの記事が2件あるとPostgresの21000エラー「ON CONFLICT DO UPDATE command cannot affect row a second time」で失敗し、そのフィードだけが無音で更新されなくなっていました。

症状

このジョブは、複数の外部RSSフィードを定期的に取得し、news_itemsテーブルへupsertしてサイネージの見出しキャッシュを更新します。1フィードの処理は取得・パース・保存をまとめてtry/catchで囲んであり、失敗したフィードだけをskipして他のフィードの処理は続ける、fail-softな作りになっています。

同一フィード内に同じURLの記事が2件含まれていると、保存呼び出しがPostgresの21000エラーで失敗します。エラーはこのtry/catchに吸収され、例外は外へ伝わりません。skipされたフィードの既存キャッシュはlast-known-goodとしてそのまま残るため、画面上は「そのニュース源だけ更新が止まっている」以上の異常が出ず、症状として気づきにくい形になります。

原因

保存処理はsaveNewsItemsが担い、1回の呼び出しで受け取った複数記事を1本のINSERT文にまとめて発行しています。

// packages/db/src/queries/news-items.ts
const rows = await tx
  .insert(newsItems)
  .values(
    items.map((it) => ({
      source: it.source,
      sourceLabel: it.sourceLabel,
      title: it.title,
      url: it.url,
      // ...
    })),
  )
  .onConflictDoUpdate({
    target: [newsItems.source, newsItems.url],
    set: {
      sourceLabel: sql`excluded.source_label`,
      title: sql`excluded.title`,
      // ...
      updatedAt: sql`now()`,
    },
  })
  .returning({ id: newsItems.id });

target: [newsItems.source, newsItems.url]は、スキーマ側のユニークインデックスux_news_items_source_urlsourceurlの組)に対応しています。

// packages/db/src/schema/news-items.ts
(t) => ({
  // フィード × 記事 URL で 1 行(再取得は upsert / ON CONFLICT (source, url))。
  uxSourceUrl: uniqueIndex("ux_news_items_source_url").on(t.source, t.url),
}),

ジョブは1フィードにつきsourceを固定した記事の配列をまとめてsaveItemsに渡します。フィードのソースは一定なので、同一フィード内に同じURLが2件あると、この1本のINSERT文の中に(source, url)が同一の行が2件混じることになります。PostgresのON CONFLICT DO UPDATEは、1本の文が処理する行の中で同じ行を2回更新することを許可しておらず、この状態を21000エラー「cannot affect row a second time」として拒否します。INSERT文の各行は同一文の中の他の行を見ずに処理されるため、事前に重複を取り除いておかない限り、文全体がまとめて失敗します。

直し方

saveItemsを呼ぶ直前に、URL単位のSetで先勝ちの重複除去を挟みました。

// apps/jobs/src/news/run.ts
// 同一フィード内で URL 重複を除去(先勝ち)。saveItems は 1 本の
// INSERT ... ON CONFLICT (source, url) DO UPDATE を発行するため、1 フィード(source 一定)に
// 同じ url が 2 件あると Postgres が 21000「ON CONFLICT DO UPDATE ... cannot affect row a
// second time」で失敗し、そのフィード全体の保存が無音で落ちる(ニュース源が更新されなくなる)。
const seenUrl = new Set<string>();
const items = mapped.filter((it) => {
  if (seenUrl.has(it.url)) return false;
  seenUrl.add(it.url);
  return true;
});
const rows = await deps.saveItems(items);

saveNewsItems自体(INSERT文やスキーマ)は変更していません。1本の文に渡す前の入力側で重複を無くすことで、ON CONFLICT DO UPDATEが同じ行を2回更新しようとする状況そのものを起こさないようにしています。

修正と同時に、同一フィード内に重複URLがある場合の挙動を固定するリグレッションテストも追加されています。

// apps/jobs/src/news/__tests__/run.test.ts
it("同一フィード内の URL 重複を除去してから saveItems に渡す(先勝ち)", async () => {
  const dup: ParsedNewsItem = {
    title: "記事A(重複・後勝ち破棄)",
    url: "https://a/1", // ITEM_A と同一 url
    publishedAt: new Date("2026-06-02T00:00:00Z"),
    summary: null,
  };
  const summary = await runNewsFetch({
    listFeeds: () => [FEED_MEXT],
    fetchFeed: async () => [ITEM_A, ITEM_B, dup],
    saveItems,
  });

  const passed = saveItems.mock.calls[0]?.[0] as ReadonlyArray<{ url: string; title: string }>;
  expect(passed).toHaveLength(2); // 重複 1 件を除去
  expect(passed.map((i) => i.url)).toEqual(["https://a/1", "https://a/2"]);
  // 先勝ち: 最初に現れた ITEM_A(記事A)が残る。
  expect(passed[0]?.title).toBe("記事A");
});

saveItemsに渡る配列が重複を含んでいたら、以降の変更でこのテストが落ちる形になっています。

見落としやすい前提

この不具合は本番の異常を追って見つかったものではなく、コードベースを対象にしたバグ探索スイープの中で見つかりました。1フィード分の記事を「1本のバッチupsert」としてまとめて扱う設計そのものが、フィード内に重複が無いことを暗黙に前提していた点が見落としの本体です。

  • (source, url)をconflict targetにしたON CONFLICT DO UPDATEは、DB側では「同じ行への複数回の更新を1文の中で許さない」という制約であり、バッチのサイズが大きいほど、入力側の重複が起きやすくなります
  • fail-softなtry/catchは「1フィードの失敗が他フィードに波及しない」ための設計ですが、同時に「失敗したという事実そのものが外から見えにくくなる」というトレードオフも持っています。個々のフィードの取得結果を見えるようにするには、failedFeedsのようなfail-soft側が返す情報を監視する仕組みが別途必要です
  • RSSフィードが同じ<link>を重複して配信すること自体は、フィード配信側の一時的な不具合や再送で起こり得ます。バッチupsertを設計する側が「入力に重複が混じる可能性」を前提に置くかどうかで、同じ種類の21000エラーは他のバッチ処理でも再現します

よくある質問

Q1ON CONFLICT DO UPDATEがcannot affect row a second timeで落ちるのはなぜ?

1本のINSERT文にconflict targetが同じ行が2件以上含まれていると、PostgresはON CONFLICT DO UPDATEの対象を同じ行に対して2回適用できず、21000エラーで文全体を失敗させます。INSERT内の各行は互いを見えないまま処理されるため、複数行をまとめて渡すバッチupsertでは、渡す前に重複を取り除く必要があります。

Q2なぜフィード全体が無音でskipされましたか?

取得ジョブは1フィードの取得・パース・保存を丸ごとtry/catchで囲み、失敗したフィードだけをfailedFeedsに積んで他のフィードの処理を続ける設計(fail-soft)になっていました。エラー自体はcatchされ、既存のキャッシュはlast-known-goodとして残るため、例外は上には伝播せず、そのフィードのニュース源だけが更新されないまま気づかれにくい形になっていました。

Q3直し方はどう変えましたか?

取得したパース済み記事をsaveItemsに渡す直前で、url単位のSetを使って先勝ちで重複を除去するようにしました。同じsource(フィード)内で同じurlが複数回出てきても、最初に見つかった1件だけを残し、以降の同一urlはフィルタで捨てます。

Q4後から現れた方の記事は保存されませんか?

されません。先勝ちなので、同一フィード内で同じurlが複数回現れた場合は最初の1件(タイトルや要約が古い可能性がある方)が残り、後続の重複は保存されずに捨てられます。フィード側のRSSがまれに同じ<link>を重複して配信するケースを想定した割り切りです。

確認した環境

  • drizzle-orm ^0.45.2 / postgres ^3.4.5
  • 2026-07-13 に修正(バグ探索スイープで発見)

この記事の根拠

  • TypeScriptファイル 115〜146行目コミット 18ac888
  • TypeScriptファイル 135〜159行目コミット 18ac888
  • TypeScriptファイル 44〜84行目コミット 2b5d7c1
  • TypeScriptファイル 40〜66行目コミット ea93c5f

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