データパイプラインの冪等性:PR が 2 つでき判定が狂った構築ログ

本番には 6 本出たのに判定は「一部だけ」。予備の bot の二重実行と、一覧の先頭 1 件だけを見る判定が原因でした。データパイプラインの冪等性を 3 つの型で整理し、MERGE 文や dbt の対策と点検表に落とします。

20分で読めます Data
2 本の経路が 1 点に合流し、箱が 1 つだけ残って 2 つ目の箱には × が付いている、冪等性を表す抽象図
この記事は AI が下書きを補助し、運営者が内容を確認して公開しています。

定期実行のデータパイプラインに、再実行や予備の仕組みを足している 1 人目のデータ担当の方向けに、データパイプラインの冪等性(べきとうせい:同じ処理を何回重ねても結果が同じになる性質)を実際のつまずきから説明します。読み終わると、自分の仕組みで二重実行が起きる場所を数え、どの型の対策を入れるかを決められるようになります。

題材は、筆者がこのサイトの運用で試した記録です。AI が毎日記事を書いて公開する仕組みで、同じ記事に PR(プルリクエスト:変更を取り込む前に確認するための依頼)が 2 つでき、結果の判定が狂いました。

この記事で分かること

  • 予備の仕組みを足したときに、なぜ二重実行が起きるのか
  • 一覧の先頭 1 件だけを見る判定の、何が危ないのか
  • 冪等性の 3 つの型(作らない・上書きする・規則で 1 つ選ぶ)と、BigQuery・dbt・Airflow での同じ対策
  • 自分の仕組みで「同じ仕事を受け持つ経路」を数える点検表

起きたこと:本番 6 本なのに判定は「一部だけ」

2026-11-09 の毎日の実行で、記事は本番に 6 本出ました。ところが日ごとの判定は partial(一部だけ成功)になり、テーマ「AI分析」は 2 本中 1 本と数えられました。

調べると、同じブランチ(作業の流れを分けるための枝)に PR が 2 つできていました。記事は本番に出ていたので、1 つは Merge(取り込み)済みで、もう 1 つは開いたままだったと考えられます。

他の工程は「1 つの記事に PR は 1 つ」という前提で作られていたため、後ろの工程が誤った結論を出しました。

AI の毎日の実行全体の作りは、launchd と claude -p で毎日の自動実行を組んだ構築ログと、同時実行数と待ち上限を決め直した構築ログにまとめています。

時刻順に見る:PR はなぜ 2 つできたのか

時刻の付いた実行の記録は残っていません。そのため図 1 は順番だけを示し、推測の部分はそう書いています。

直す前は push の後に本流の PR と補助の bot の PR が 2 つでき、判定が開いた PR を見て一部だけと判断した流れと、直した後は bot が動かず判定が Merge 済みを選ぶ流れの比較

順出来事確かめられたこと
1記事のブランチへ push(送信)する事実
2本流の公開スクリプトが PR を作る事実
3PR が Merge され、本番に出る事実(本番 6 本)
4push で起動した補助の bot が、開いた PR が無いので 2 つ目の PR を作るPR が 2 つあったのは事実、順番は推測
5判定が PR 一覧の先頭 1 件だけを見て、開いた PR を「まだ終わっていない」と読む先頭だけを見たのは事実、先頭がどれだったかは推測
6日ごとの判定が partial になる事実

図 1 の上段のとおり、本流の PR が先に Merge され、その後で bot が「開いた PR は無い」と判断して作り直したと考えられます。

原因 1:一覧の先頭 1 件だけを見る判定

判定のスクリプトは、同じブランチの PR 一覧を取り、その先頭 1 件だけを見ていました。1 つの記事に PR は 1 つ、という前提なら、これで問題は起きません。

GitHub の PR 一覧の API(プログラムから情報を取り出す窓口)の仕様を読むと、危うさが見えます。2026-11-10 時点の公式文書にある主な既定値は次のとおりです。

項目既定先頭 1 件だけを見るときの影響
state(状態の絞り込み)open(開いた PR だけ)Merge 済みの PR を見るには all の指定が要る
sort と direction(並び順)作成日時の新しい順同じブランチに PR が増えると、最新の PR が先頭になる

並び順は作成日時の新しい順が既定です。そのため、後から bot が作った開いた PR が先頭に来て、Merge 済みの PR が見落とされたと考えられます。つまり、この判定は結論を API の並び順の既定に預けていました。

原因 2:予備の bot が、すでに終わった仕事をやり直した

補助の bot は、GitHub Actions(GitHub 上で処理を自動で動かす仕組み)の workflow(自動処理の手順書)です。記事のブランチへの push すべてで起動し、開いた PR が無ければ PR を作っていました。push で workflow が動くことは、GitHub のイベントの説明に書かれています。

この bot は、もともと PR を作り忘れたときの予備でした。今は本流の公開スクリプトも、記事を作る別の workflow も、どちらも自分で PR を作ります。つまり PR 作成という 1 つの仕事を、2 つの経路が受け持っていました。

bot の確認は「開いた PR があるか」だけでした。Merge 済みの PR は開いた PR ではないので、bot にとっては「PR が無い」状態に見えます。こうして、終わった仕事がもう一度始まりました。

原因 3:後片付け(ブランチ削除)が効いていなかった

同じ見直しで、Merge のときにブランチを消す指定が効いていないことも分かりました。gh(GitHub をコマンドで操作する道具)の gh pr merge --delete-branch は、手元のブランチを消してから、リモート(GitHub 上)のブランチを消します。

ところが、作業場所が detached HEAD(特定のブランチに居ない状態)だと、手元の削除が「could not determine current branch」で止まりました。その結果、リモートの削除まで届きませんでした。筆者は gh 2.78.0 のソースを読んで、この流れを確かめています。

今回の二重の PR に、残ったブランチが直接どれだけ効いたかは確認できていません。ただ、消したはずの物が残るという意味で、同じ見直しで直すべき問題だと筆者は考えています。

経路の数を数える:重なっていたのは 1 工程だけ

工程ごとに、その工程を始められる経路がいくつあったかを数えました。数えたのは、毎日の AI 記事のブランチについて、筆者の実践の記録で確かめられた経路だけです。図 2 は直す前の構造、図 3 は直す前と後の比較です。

PR 作成には本流と補助の bot の 2 つの経路があり、ブランチ削除と判定はそれぞれ 1 つの経路だが、黙って失敗する作りと先頭 1 件だけを見る作りだったことを示す構造図

工程ごとの経路の数を直す前と直した後で比べた棒グラフ。PR 作成は 2 から 1 に減り、ブランチ削除と判定は 1 のまま

工程直す前の経路の数直した後の経路の数直す前の弱点
PR 作成21本流と bot の両方が作る
ブランチ削除11detached HEAD で黙って失敗する
判定11一覧の先頭 1 件だけを見る

Merge の経路の数は、今回の記録からは確認できなかったため表に入れていません。数え方と分類は筆者によるもので、一次情報はありません。

数えて分かったのは、重なっていた工程は PR 作成の 1 つだけだったことです。それなのに、その下流にある判定が、重複に耐えない作りでした。重複を 1 か所で起こすと、下流の「1 つしかないはず」という前提が崩れます。

これとは別に、後片付けのブランチ削除も黙って失敗していました。こちらは detached HEAD が原因で、PR の重複が無くても起きます。

冪等性の 3 つの型で整理する

データパイプラインの冪等性の対策は、筆者の整理では 3 つの型に分けられます。図 4 は、その 3 つの型を今回の対策と BigQuery・dbt・Airflow に対応させた表です。

冪等性の 3 つの型である作らない・上書きする・規則で 1 つ選ぶについて、今回の対策と BigQuery・dbt・Airflow での同じ対策を並べた対応表

型考え方直す前直した後
A 同じ入力なら作らないすでにあるかを確かめてから作る一部(開いた PR だけを確認)満たす(開いた PR か Merge 済みがあれば作らない)
B すでにあれば上書きする同じ鍵の行を新しい値で置き換える該当なし該当なし
C 複数あれば規則で 1 つ選ぶ並び順に頼らず、選び方を決めておく満たさない(先頭 1 件)満たす(Merge 済みを優先)

3 つの型のうち、直す前に満たしていた型は 0 で、直した後は 2 つになりました(筆者の分類)。上書きの型は、今回の対策には出てきません。

ブランチ削除の修正は、「後片付けが確実に効く」という別の性質として扱い、どの型にも入れていません。

直した内容と、テストで確かめたこと

直した内容は 3 つです。

対象直す前直した後
判定(pickPr という関数)PR 一覧の先頭 1 件Merge 済み → 開いた PR → 先頭 の順に選ぶ
補助の bot記事のブランチへの push すべてで動き、開いた PR だけを確認名前に日付が入った、毎日の AI 記事のブランチでは動かない。他のブランチでも、開いた PR か Merge 済みがあれば作らない
ブランチ削除リポジトリの指定なしリポジトリを指定して、手元の削除を飛ばす

bot の確認では、PR を state に all を指定して取るように変えました。Merge せずに閉じただけの PR しか無いときは、前の動きを変えないため、これまでどおり作り直します。

ブランチ削除は、次のようにリポジトリを指定します。リポジトリを指定すると、gh は手元のブランチを消す処理を飛ばし、リモートの削除だけを行います(筆者が gh のソースで確認)。

# OWNER/REPO はダミーです。自分のリポジトリ名に置き換えます
gh pr merge <PR番号> --delete-branch --repo OWNER/REPO

判定には新しいテストを 5 件足しました。1 つは「開いた PR が先頭にあっても、Merge 済みの PR を選ぶ」です。残り 4 件の内訳は、この記事の元にした記録では確認できませんでした。

手元で node --test を動かすと、全部で 116 件が合格し、失敗は 0 でした。

データパイプラインに当てはめる:MERGE 文・増分モデル・再実行

同じ 3 つの型は、データパイプラインの対策にそのまま対応します。

型データパイプラインでの対策一次情報
A 作らないMERGE 文の WHEN NOT MATCHED THEN INSERT(一致する行が無いときだけ追加)BigQuery の DML 文法
B 上書きするINSERT の代わりに UPSERT を使う。dbt の増分モデルで unique_key を指定する。BigQuery では MERGE 文の WHEN MATCHED THEN UPDATE で値を置き換える(足し算はしない。筆者の対策)Airflow のベストプラクティス、dbt の増分モデル
C 規則で 1 つ選ぶ更新先の 1 行に更新元の行が複数一致すると MERGE はエラーになる(公式)→ 先に更新元の行を 1 つへ絞る(筆者の対策)エラーになることは BigQuery の DML 文法、絞る対策は筆者の考え

Airflow(定期実行の処理を組み立てて動かす道具)のベストプラクティスは、タスクは失敗するとリトライ(自動の再実行)されうるので、再実行のたびに同じ結果を出すべきだと書いています。再実行で INSERT を使うと行が重複しうるので、UPSERT(あれば更新、無ければ追加)に置き換えるよう勧めています。また、最新のデータを読まず、決まったパーティション(日付などで区切ったデータのまとまり)を読み書きするよう勧めています。

BigQuery の MERGE 文は、追加・更新・削除を 1 つの文にまとめ、アトミック(全部成功するか、全部取り消されるか)に実行できます。公式の例は次のとおりです。

MERGE dataset.Inventory T
USING dataset.NewArrivals S
ON T.product = S.product
WHEN MATCHED THEN
  UPDATE SET quantity = T.quantity + S.quantity
WHEN NOT MATCHED THEN
  INSERT (product, quantity) VALUES (product, quantity)

この例は一致した行の数量を足す書き方なので、同じ入力で 2 回流すと数量がもう一度足されます(筆者の読み)。

dbt(SQL でデータの変換を管理する道具)の増分モデル(前回から増えた分だけを処理する作り)では、unique_key を指定すると、同じ鍵の古い行を新しい行で置き換えます。指定しないと、ほとんどの環境で追記だけになり、重複した行もそのまま入ります。

型 B には、行ごとに置き換える方法のほかに、DELETE+INSERT(入れ直す前に、同じ日付などの範囲を先に消す方法)もあります。上で触れた、決まったパーティションを読み書きする勧めと組み合わせると、同じ日を何回流しても、その日の行は 1 回分だけ残ります(筆者の整理)。

型 C の例として、BigQuery では、更新先の 1 行に更新元の行が複数一致すると「UPDATE/MERGE must match at most one source row for each target row.」というエラーになります。ここまでが公式文書の記述です。筆者の対策としては、今回の判定と同じく、複数の候補から 1 つを選ぶ規則を先に決め、MERGE の前に更新元の行を 1 つへ絞っておきます。

点検表:同じ仕事を受け持つ経路を数える

二重実行が起きる場所は、次の順で点検します。

  1. 工程を書き出します(取り込み・変換・書き込み・後片付け・判定など)。
  2. 工程ごとに、それを始められる経路を数えます(定期実行・再実行・手動実行・予備の仕組みなど)。
  3. 経路が 2 つ以上ある工程に印を付けます。
  4. 印を付けた工程が、型 A か型 B のどちらを満たしているかを確かめます。
  5. その下流で「1 つしかないはず」と前提している工程を探し、型 C の規則があるかを確かめます。
  6. 後片付けの処理が、失敗したときに止まって知らせるかを確かめます。
確かめること満たさないと起きること
経路が 2 つ以上の工程は、すでにあるかを確かめてから作るか終わった仕事がやり直され、重複ができる
書き込みは、上書きか一致しないときだけの追加か再実行のたびに行が増える
一覧から 1 件を選ぶ処理に、並び順以外の規則があるか重複ができたとき、誤った 1 件を選ぶ
後片付けは、失敗を黙って見過ごさないか残った物が次の処理の入力になる

今回の仕組みでは、点検の 4 番目から 6 番目の手順が、実際に壊れていた場所です。4 番目は、bot が開いた PR だけを確かめていたので、型 A を一部しか満たしていませんでした。

つまずきやすい点

予備の経路は、主の経路と同じ確認をしているとは限りません。 今回の bot は、本流が後から PR を作るようになった時点で役目が重なりました。予備を足すときは、主の経路が「終わった」と記録する場所を、予備も見るようにします。

MERGE 文を使えば冪等になる、とは限りません。 上の公式の例は、一致したときに数量を足しています。同じ入力で 2 回流すと、数量が 2 回足されると筆者は読みました。冪等にしたいなら、値を置き換える書き方にするか、取り込み済みを別に記録する必要があると考えられます。

既定値に結論を預けないようにします。 PR 一覧の state の既定は open で、並び順の既定は新しい順です。判定に効く値は、既定に任せず自分で指定すると、読み手にも意図が伝わります。

確かめられなかったことは、確かめられなかったと残します。 bot が 2 つ目の PR を作らないことは、push で動く workflow を手元で起動できないため、実行の記録では確かめられませんでした。設定を読んで確かめただけです。次の実行で同じブランチに PR が 1 つだけになっているかを見る必要があります。

なお、本番へ出す経路そのものの検査の設計は、AI の成果物を人の確認なしで本番へ出す経路の設計で説明しています。

まとめ

  • 予備の経路を足すと、1 つの仕事を 2 つの経路が受け持ちます。予備も「すでに終わったか」を確かめる型 A の対策が要ります。
  • 一覧の先頭 1 件だけを見る判定は、並び順の既定に結論を預けています。複数あるときに 1 つを選ぶ規則(型 C)を先に決めます。
  • 点検では、工程ごとに経路を数え、2 つ以上ある工程とその下流を確かめます。

自分の仕組みで二重実行に耐える作りかを見直したい方、AI に任せる範囲の決め方を相談したい方は、お問い合わせからご連絡ください。MERGE 文や増分の集計の SQL の確認に不安がある場合は、BigQuery SQL レビューパックも用意しています。

参考にした一次情報