Rebounder Tech Blog

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

Drizzleのupsert(onConflictDoUpdate)はCOALESCEが無いと直前の値をnullで潰す

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

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

結論

Drizzleのupsert(onConflictDoUpdate)でsetにCOALESCEを書かないと、新データが部分的にnullな時間帯に、直前まで保持していた実測値をnullで上書きしてしまう。

結論

Drizzleの.onConflictDoUpdate()は、setに渡した値をそのまま既存行に上書きします。 新データの取得元が一時的に一部の列だけnullを返す時間帯があると、素朴にset: { pm25, uvIndex, ... }と書いただけでは、直前まで保持していた正常な実測値がnullで潰されます。直し方は、値を保持したい列だけcoalesce(excluded.列, テーブル.列)に変えることです。

症状

サイネージに表示している大気質(PM2.5・光化学オキシダント・UV指数)と暑さ指数(WBGT)は、外部データ(そらまめ君・環境省)を定期取得してPostgreSQLへupsertしています。ある時間帯だけ、その日ずっと表示されていたはずの実測PM2.5や、記録していたはずのWBGTピーク値が、ウィジェット上で「—」に変わる現象がありました。

エラーは出ません。取得ジョブ自体は成功しています。

原因

そらまめ君・環境省のCSVは、HTMLフォールバックに切り替わったり該当測定局のデータが無かったりする時間帯に、値が全てnullで返ってくることがあります。

修正前のair-quality.tsは、upsert時にその新しい値をそのままsetへ渡していました。

.onConflictDoUpdate({
  target: [airQualityIndex.areaCode, airQualityIndex.source, airQualityIndex.forecastDate],
  set: {
    areaName: input.areaName ?? null,
    fetchedAt: input.fetchedAt ?? new Date(),
    pm25,
    pm25Band,
    oxidant,
    uvIndex,
    uvBand,
    raw: rawValue,
    updatedAt: new Date(),
    updatedBy: null,
  },
})

areaCode / source / forecastDate(=当日)が競合キーなので、同じ日に何度も再取得するたびにこのonConflictDoUpdateが走ります。取得結果がnullに倒れた回の実行では、pm25uvIndexにもnullが入ったままsetに渡り、既存行にあった直前の実測値をそのままnullで上書きしていました。heat-alerts.tsのWBGT(wbgtMax / wbgtBand)も同じ構造で同じ事故が起きます。

競合キーにforecastDate(当日)が含まれるため、この上書きは「同日内」に限られます。日をまたげば別行になるので影響は当日のうちに収まりますが、その当日のうちはウィジェットが残りの時間帯すべて「—」のままになります。

直し方

保持したい列だけ、sqlテンプレートでcoalesce(excluded.列, テーブル.列)に変えました。excluded.列が今回のINSERT値(取れなければnull)、テーブル.列が上書き前の既存行の値です。

.onConflictDoUpdate({
  target: [airQualityIndex.areaCode, airQualityIndex.source, airQualityIndex.forecastDate],
  set: {
    areaName: input.areaName ?? null,
    fetchedAt: input.fetchedAt ?? new Date(),
    pm25: sql`coalesce(excluded.${sql.raw(airQualityIndex.pm25.name)}, ${airQualityIndex.pm25})`,
    pm25Band: sql`coalesce(excluded.${sql.raw(airQualityIndex.pm25Band.name)}, ${airQualityIndex.pm25Band})`,
    oxidant: sql`coalesce(excluded.${sql.raw(airQualityIndex.oxidant.name)}, ${airQualityIndex.oxidant})`,
    uvIndex: sql`coalesce(excluded.${sql.raw(airQualityIndex.uvIndex.name)}, ${airQualityIndex.uvIndex})`,
    uvBand: sql`coalesce(excluded.${sql.raw(airQualityIndex.uvBand.name)}, ${airQualityIndex.uvBand})`,
    raw: rawValue,
    updatedAt: new Date(),
    updatedBy: null,
  },
})

coalesceは左から順に見て最初のnon-null値を返すので、excluded.pm25(新データ)がnullのときだけ既存のpm25(直前の実測値)が残ります。新データが値を持っていれば、そちらが優先されて正しく更新されます。

heat-alerts.ts側は、全列一律にはしていません。alertLevelは無条件更新のままです。

set: {
  areaName: input.areaName ?? null,
  fetchedAt: input.fetchedAt ?? new Date(),
  alertLevel,
  wbgtMax: sql`coalesce(excluded.${sql.raw(heatAlerts.wbgtMax.name)}, ${heatAlerts.wbgtMax})`,
  wbgtBand: sql`coalesce(excluded.${sql.raw(heatAlerts.wbgtBand.name)}, ${heatAlerts.wbgtBand})`,
  raw: rawValue,
  updatedAt: new Date(),
  updatedBy: null,
}

alertLevelは夕方にsevereからnoneへ落ちるような、値そのものが変わることが正しい列です。ここまでCOALESCEにすると、本来なら反映すべき「警戒レベルが下がった」という更新まで直前の値に固定されてしまいます。COALESCEで守るべきは「一時的にnullへ倒れうる測定値」だけで、「正当にnullや別の値へ遷移しうる状態」まで一律に保護すると別の不具合を生みます。

schema(列定義)自体は変えていないため、この修正にマイグレーションは不要でした。

再発防止

このパターンは、weather_forecastsの気温列で先に採用されていた対処です。今回はそれを大気質とWBGTに横展開したもので、コード側にも判断の理由をコメントとして残しています。

// ★ 測定値は新値が null なら既存値を保持(weather-forecasts.ts の気温と同じ対処)。
// そらまめが HTML フォールバック / 該当局なしで全 null に倒れる時間帯があり、非 COALESCE の
// 上書きだと同日先行時間帯の実測 PM2.5 を null で潰し、サイネージが残りの一日「—」になる。

外部データソースをupsertで取り込む処理を新しく書くときは、「新しい取得が失敗ではなく成功しつつも部分的にnullを返しうるか」を先に確認し、そうであればsetに渡す前に素朴な代入とCOALESCEのどちらが適切かを列ごとに判断する、という基準がこのコメントの形で残っています。

よくある質問

Q1なぜCOALESCEが無いと直前の値が消えるのですか?

onConflictDoUpdateのsetは、渡した値をそのまま既存行に書き込みます。外部APIやCSVがHTMLフォールバックや該当局なしで一時的に全項目nullを返す時間帯があると、そのnullがそのままsetに渡り、直前まで保持していた実測値を上書きして消してしまいます。

Q2alertLevelだけCOALESCEにしなかったのはなぜですか?

alertLevelは無条件更新のままにしています。夕方にsevereからnoneへ落ちるような正当な状態遷移を許すためで、COALESCEにすると本来更新すべき値まで直前の値に固定されてしまいます。COALESCEにしたのはWBGTの数値とバンドだけです。

Q3この修正にマイグレーションは必要でしたか?

不要でした。COALESCEはupsertのSET句の書き方を変えるだけで、テーブルのスキーマ(列定義)自体は変わっていないためです。

Q4同じ対処は他のテーブルにもありますか?

weather_forecastsの気温列にすでに同じCOALESCEの対処が入っており、そこでの実績を大気質(PM2.5・オキシダント・UV)と暑さ指数(WBGT)のupsertにも横展開しました。

確認した環境

  • drizzle-orm ^0.45.2 / PostgreSQL
  • 2026-07-13 に fix commit で修正(バグ探索スイープの1件)

この記事の根拠

  • TypeScriptファイル 97〜112行目コミット 0da560c
  • TypeScriptファイル 97〜116行目コミット 97c43ca
  • TypeScriptファイル 87〜100行目コミット 0da560c
  • TypeScriptファイル 87〜104行目コミット 97c43ca

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