Rebounder Tech Blog

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

気象庁APIの夕方発表をそのまま upsert すると今日の気温が消える

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

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

結論

気象庁の天気予報APIは17時の夕方発表で本日分の気温を予報から落とすため、そのままupsertすると朝に取得済みだった気温がnullで上書きされる。

結論

気象庁(JMA)の天気予報APIは、17時の夕方発表で「本日」の気温だけを予報から落とす。 天気コード・天気テキスト・降水確率は夕方発表にも引き続き載るが、本日の最高・最低気温はすでに観測時刻を過ぎているため、予報値として提供されなくなる。

この欠落を素朴に ON CONFLICT ... DO UPDATE で上書きすると、朝の発表で取得済みだった本日の気温が null で消える。 直し方は、upsert の競合更新で気温だけ COALESCE(excluded.列, 既存の列) にし、新値が null なら既存値を保持する形にする。

症状

サイネージ盤面の「今日」の天気で、最高・最低気温だけが「— / —」表示になる。天気アイコンや降水確率は正しく出ている。障害ではなくデータの見え方だけの問題なので、エラーログには何も残らない。

原因

天気予報の取得は定期実行され、(area_code, source, forecast_date) を一意キーに upsertWeatherForecast で1地域・1対象日ぶんを upsert する。朝の取得では本日分の気温が正しい値で入る。

ところが JMA の17時発表(夕方版)では、本日の天気コード・天気テキスト・降水確率は引き続き提供される一方、本日の気温は予報から落ちる(その日の最高はもう観測済みで、予報として出す意味がなくなるため)。パーサはこの欠落をそのまま tempMin: null, tempMax: null として同じ対象日の行に渡す。

問題はここからで、この夕方分の再取得も同じ (area_code, source, forecast_date) に対する upsert になる。競合更新(onConflictDoUpdate)が気温列を無条件に新値で上書きしていたため、朝に入っていた本日の実気温が、夕方の null 取得によって消される。 表示側は null を正しく「—」と描画しているだけで、表示層に不具合はない。原因は upsert の競合解決が「最新の取得を常に正とする」前提で書かれていたことにある。

直す

upsert の競合更新で、気温だけ COALESCE(excluded.temp_x, 既存の temp_x) にする。新値が null のときだけ既存値を使い、非 null の新値が来れば従来どおり上書きする。

.onConflictDoUpdate({
  target: [weatherForecasts.areaCode, weatherForecasts.source, weatherForecasts.forecastDate],
  set: {
    weatherCode: input.weatherCode ?? null,
    weatherText: input.weatherText ?? null,
    // 気温は新値が null なら既存値を保持する。
    // excluded.temp_x = 今回の INSERT 値、weatherForecasts.temp_x = 既存行の値。
    tempMin: sql`coalesce(excluded.${sql.raw(weatherForecasts.tempMin.name)}, ${weatherForecasts.tempMin})`,
    tempMax: sql`coalesce(excluded.${sql.raw(weatherForecasts.tempMax.name)}, ${weatherForecasts.tempMax})`,
    pop: input.pop ?? null,
    // ...
  },
})

対象を気温だけに絞っているのが要点。天気コード・天気テキスト・降水確率は本日でも夕方発表に常に含まれるため、これらは従来どおり新値でそのまま上書きしてよい。「欠落しうるフィールドだけ」を選んで保持する形にしないと、本当に新しい情報(訂正や更新)まで古いまま固定してしまう。

古い気温が残り続ける心配もない。対象日の予報は日付が過ぎれば読み取りクエリの対象から外れるため、保持した値が翌日以降にまで表示され続けることはない。

気づけなかった理由

このバグはエラーを出さない。取得ジョブは正常終了し、DBへの書き込みも成功し、表示層も null を仕様どおり「—」として描画する。どの層も「自分の責務は正しく果たしている」状態のまま、気温だけが静かに消える。

気づけたのは、17時前後で同じ対象日を2回取得しているという運用の形そのものに注目したときだった。「後から来た取得は、常に前の取得より新しく正しい」という upsert の暗黙の前提が、フィールドによっては成り立たない外部APIがある、という点が盲点だった。定期取得してDBに書き込むキャッシュ全般に共通する形の問題で、次に似た構成のAPI連携を書くときに立ち返れるよう、直し方は実装のドキュメントコメントに残してある。

よくある質問

Q1なぜ天気コードや降水確率は消えないのに気温だけ消えるのですか?

夕方発表は本日の天気コード・天気テキスト・降水確率は引き続き提供します。本日の最高気温はすでに観測時刻を過ぎているため、予報値としての本日気温だけが提供されなくなります。取得側のパーサはこの欠落をnullとしてそのまま渡すため、気温だけが選択的に失われます。

Q2COALESCEで気温を保持すると、古い気温がいつまでも残りませんか?

残りません。対象日の予報は日付が過ぎると読み取り対象から外れるため、古い値が翌日以降に表示され続けることはありません。新しい非null値が来れば従来どおり上書きされるため、正当な更新・訂正はそのまま反映されます。

Q3この直し方は他の外部APIキャッシュにも使えますか?

使えます。定期取得した値をDBへupsertするキャッシュ全般で、再取得のたびに一部フィールドだけ欠落しうるなら、そのフィールドだけ COALESCE(excluded.列, 既存.列) にすることで、欠落した再取得が既存の正常値を巻き込んで潰すのを防げます。

この記事の根拠

  • TypeScriptファイル 51〜120行目コミット 1a31ee1

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