Store stub records on empty weather fetches to prevent infinite backfill retries

When ASOS/RAOB/IEMRE APIs return empty data, store a stub record so
dedup checks (has_surface_observations?, has_sounding?, has_iemre_observation?)
see coverage and don't recreate the same jobs on future backfill runs.
This commit is contained in:
Graham McIntire 2026-04-06 16:10:28 -05:00
parent a6ae6ce973
commit 646467cf4d
4 changed files with 62 additions and 6 deletions

View file

@ -25,7 +25,9 @@ defmodule Microwaveprop.Workers.IemreFetchWorker do
case IemClient.fetch_iemre(lat, lon, date) do
{:ok, []} ->
Logger.info("IEMRE returned empty data for #{lat},#{lon} @ #{date_str}")
# Store stub so this lat/lon/date isn't retried on future backfills
Weather.upsert_iemre_observation(%{lat: lat, lon: lon, date: date, hourly: []})
Logger.info("IEMRE: no data available for #{lat},#{lon} @ #{date_str}, stored stub")
:ok
{:ok, data} ->

View file

@ -10,6 +10,7 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.SoundingParams
alias Microwaveprop.Weather.Station
alias Microwaveprop.Weather.SurfaceObservation
require Logger
@ -50,6 +51,15 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do
if row.observed_at, do: Weather.upsert_surface_observation(station, row)
end)
# Store stub so this station/window isn't retried on future backfills
if count == 0 do
midpoint = DateTime.add(start_dt, div(DateTime.diff(end_dt, start_dt), 2))
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{station_id: station.id, observed_at: midpoint})
|> Repo.insert(on_conflict: :nothing, conflict_target: [:station_id, :observed_at])
end
Logger.info("WeatherFetch ASOS: #{station_code} ingested #{count} observations")
Phoenix.PubSub.broadcast(
@ -132,7 +142,14 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do
:ok
{:ok, []} ->
Logger.info("WeatherFetch RAOB: #{station_code} returned no data for #{sounding_time_str}")
# Store stub sounding so this station/time isn't retried on future backfills
Weather.upsert_sounding(station, %{
observed_at: sounding_time,
profile: [],
level_count: 0
})
Logger.info("WeatherFetch RAOB: #{station_code} no data for #{sounding_time_str}, stored stub")
:ok
{:error, reason} ->

View file

@ -60,7 +60,7 @@ defmodule Microwaveprop.Workers.IemreFetchWorkerTest do
assert length(obs.hourly) == 2
end
test "returns :ok on empty data" do
test "stores stub on empty data so backfill doesn't retry" do
Req.Test.stub(IemClient, fn conn ->
Req.Test.json(conn, %{"data" => []})
end)
@ -74,7 +74,12 @@ defmodule Microwaveprop.Workers.IemreFetchWorkerTest do
}
assert :ok = IemreFetchWorker.perform(job)
assert Repo.aggregate(IemreObservation, :count) == 0
assert Repo.aggregate(IemreObservation, :count) == 1
obs = Repo.one!(IemreObservation)
assert obs.hourly == []
assert obs.lat == 32.875
assert obs.lon == -97.0
end
test "retries on transient server error" do

View file

@ -122,6 +122,33 @@ defmodule Microwaveprop.Workers.WeatherFetchWorkerTest do
assert {:error, "IEM ASOS HTTP 503"} = WeatherFetchWorker.perform(%Oban.Job{args: args})
end
test "stores stub observation on empty ASOS data so backfill doesn't retry", %{station: station} do
Req.Test.stub(IemClient, fn conn ->
body = """
#DEBUG,
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
"""
Plug.Conn.send_resp(conn, 200, body)
end)
args = %{
"fetch_type" => "asos",
"station_id" => station.id,
"station_code" => "KORD",
"start_dt" => "2026-03-28T16:53:00Z",
"end_dt" => "2026-03-28T20:53:00Z"
}
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
assert Repo.aggregate(SurfaceObservation, :count) == 1
obs = Repo.one!(SurfaceObservation)
assert obs.station_id == station.id
# All weather fields nil — it's a stub
assert is_nil(obs.temp_f)
end
test "returns :ok when station no longer exists" do
Req.Test.stub(IemClient, fn conn ->
Plug.Conn.send_resp(conn, 500, "Should not be called")
@ -185,7 +212,7 @@ defmodule Microwaveprop.Workers.WeatherFetchWorkerTest do
assert Repo.aggregate(Sounding, :count) == 1
end
test "handles empty profiles", %{station: station} do
test "stores stub sounding on empty profiles so backfill doesn't retry", %{station: station} do
Req.Test.stub(IemClient, fn conn ->
Req.Test.json(conn, %{"profiles" => []})
end)
@ -198,7 +225,12 @@ defmodule Microwaveprop.Workers.WeatherFetchWorkerTest do
}
assert :ok = WeatherFetchWorker.perform(%Oban.Job{args: args})
assert Repo.aggregate(Sounding, :count) == 0
assert Repo.aggregate(Sounding, :count) == 1
sounding = Repo.one!(Sounding)
assert sounding.profile == []
assert sounding.level_count == 0
assert sounding.station_id == station.id
end
test "returns error on HTTP failure", %{station: station} do