Duplicate URLs in a Feed Break ON CONFLICT DO UPDATE
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Two articles with the same URL in one feed make a single INSERT ... ON CONFLICT (source, url) DO UPDATE fail with Postgres 21000, and a fail-soft catch skips that whole feed in silence.
Conclusion
Upserting in one batched INSERT means two rows with the same conflict target fail the entire statement. A job that fetches news articles and saves them to the database issued one INSERT ... ON CONFLICT (source, url) DO UPDATE per feed. With two articles sharing a URL inside the same feed, it failed with Postgres 21000, ON CONFLICT DO UPDATE command cannot affect row a second time, and that feed stopped updating in silence.
Symptom
The job periodically fetches several external RSS feeds and upserts into a news_items table to refresh the signage headline cache. Processing one feed — fetch, parse, save — is wrapped in a try/catch so a failing feed is skipped and the others continue: fail-soft.
When one feed contains two articles with the same URL, the save call fails with Postgres 21000. The error is absorbed by that try/catch and nothing propagates outward. The skipped feed’s existing cache stays as last-known-good, so on screen there is nothing beyond “this one news source has stopped updating” — a symptom that is easy to miss.
Cause
Saving is handled by saveNewsItems, which issues one INSERT statement for all the articles it receives in a call.
// 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] corresponds to the unique index ux_news_items_source_url on the schema side, over source and url.
// packages/db/src/schema/news-items.ts
(t) => ({
// one row per feed x article URL (re-fetch upserts via ON CONFLICT (source, url)).
uxSourceUrl: uniqueIndex("ux_news_items_source_url").on(t.source, t.url),
}),
The job passes saveItems an array of articles with source fixed for one feed. Since the source is constant, two articles sharing a URL within a feed put two rows with an identical (source, url) into that single INSERT. Postgres’s ON CONFLICT DO UPDATE does not permit updating the same row twice within one statement and rejects it as 21000, cannot affect row a second time. Rows in an INSERT are processed without seeing the other rows of the same statement, so unless duplicates are removed beforehand, the whole statement fails together.
The fix
A first-occurrence-wins deduplication keyed on URL was inserted just before calling saveItems.
// apps/jobs/src/news/run.ts
// Remove duplicate URLs within one feed (first wins). saveItems issues a single
// INSERT ... ON CONFLICT (source, url) DO UPDATE, so two rows with the same url in one feed
// (source constant) make Postgres fail with 21000 "ON CONFLICT DO UPDATE ... cannot affect
// row a second time", and the whole feed's save is silently lost (the source stops updating).
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 itself — the INSERT statement and the schema — is unchanged. Removing duplicates on the input side, before anything is passed to a single statement, prevents the situation where ON CONFLICT DO UPDATE would try to update the same row twice from arising at all.
A regression test pinning the behaviour with duplicate URLs in one feed was added with the fix.
// apps/jobs/src/news/__tests__/run.test.ts
it("removes duplicate URLs within one feed before passing to saveItems (first wins)", async () => {
const dup: ParsedNewsItem = {
title: "記事A(重複・後勝ち破棄)",
url: "https://a/1", // same url as ITEM_A
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); // one duplicate removed
expect(passed.map((i) => i.url)).toEqual(["https://a/1", "https://a/2"]);
// First wins: ITEM_A, which appeared first, survives.
expect(passed[0]?.title).toBe("記事A");
});
An assumption worth naming
A batch upsert cannot see its own rows. Each row inside one INSERT is processed without reference to the others, so a duplicate conflict target is only detected by Postgres refusing the whole statement. Wherever several rows are passed to one ON CONFLICT DO UPDATE, deduplicating on the input side is a precondition, not an optimisation.
A fail-soft catch converts this into silence. Skipping a failed feed and continuing is the right design for a job fetching many external sources — but it also means the only observable symptom is one source quietly not updating. The exception never reaches anyone.
This was found in a bug-hunting sweep, not in production. Nobody reported it. That is consistent with the shape of the failure: the cache stays as last-known-good, so the screen keeps showing plausible headlines from before the break.
よくある質問
Q1Why does ON CONFLICT DO UPDATE fail with 'cannot affect row a second time'?
If one INSERT statement contains two or more rows with the same conflict target, Postgres cannot apply ON CONFLICT DO UPDATE to the same row twice and fails the whole statement with 21000. Rows inside an INSERT cannot see each other, so a batch upsert has to deduplicate before it is passed.
Q2Why was the whole feed skipped silently?
The job wraps fetching, parsing and saving one feed in a try/catch, pushing the failed feed onto failedFeeds and continuing with the others — fail-soft. The error is caught, the existing cache remains as last-known-good, and nothing propagates, so only that news source stops updating.
Q3What did the fix change?
Just before passing the parsed articles to saveItems, a Set keyed on url removes duplicates, first occurrence wins. If the same url appears more than once within one source, only the first is kept and later duplicates are filtered out.
Q4Is the later article lost?
Yes. First occurrence wins, so within one feed the first of a repeated url survives, even though its title or summary may be older. It is a deliberate trade-off for feeds that occasionally emit the same <link> twice.
確認した環境
- drizzle-orm ^0.45.2 / postgres ^3.4.5
- Fixed 2026-07-13 (found in a bug-hunting sweep)
この記事の根拠
- TypeScriptファイル 115〜146行目コミット 18ac888
- TypeScriptファイル 135〜159行目コミット 18ac888
- TypeScriptファイル 44〜84行目コミット 2b5d7c1
- TypeScriptファイル 40〜66行目コミット ea93c5f
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。