prop/test/microwaveprop/workers/backfill_enqueue_worker_test.exs
Graham McIntire 79e16ae9d3
Give up on stuck :queued backfill contacts after 3 days
Contacts whose weather/hrrr/terrain/iemre status has been :queued for
more than 3 days (144 cron cycles) are flipped to :unavailable. The
set_enrichment_status!/3 helper is a no-op when the status is unchanged,
so a stuck row retains a stale updated_at — which is exactly the signal
we need for 'the cron has already tried and the data doesnt exist
upstream' (dead ASOS station, pre-2014 HRRR, out-of-grid IEMRE). Clears
~1,492 pre-existing stuck contacts and stops them from churning the
backfill queue.
2026-04-13 17:20:18 -05:00

231 lines
7.8 KiB
Elixir

defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Workers.BackfillEnqueueWorker
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert()
contact
end
setup do
Req.Test.stub(IemClient, fn conn ->
case conn.request_path do
"/cgi-bin/request/asos.py" ->
Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
"/iemre/" <> _ ->
Req.Test.json(conn, %{"data" => [%{"utc_hour" => 0, "p01m_mm" => 0.0}]})
_ ->
Req.Test.json(conn, %{"profiles" => []})
end
end)
Req.Test.stub(HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
# ERA5 goes through Copernicus CDS API which is async (submit → poll → download)
# and has no Req.Test plug hook, so inline Oban would block on Process.sleep.
# These tests exercise the non-ERA5 enrichment path; ERA5 has its own coverage.
@non_era5_types ["hrrr", "weather", "terrain", "iemre"]
describe "perform/1" do
test "enqueues enrichment jobs for pending contacts" do
create_contact()
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
end
test "does not enqueue for already-complete contacts" do
contact = create_contact()
contact
|> Ecto.Changeset.change(%{
hrrr_status: :complete,
weather_status: :complete,
terrain_status: :complete,
iemre_status: :complete
})
|> Repo.update!()
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
end
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 2, "types" => @non_era5_types}
})
end
test "broadcasts enqueue_complete with count" do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
create_contact()
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert_receive {:enqueue_complete, 1}
end
end
describe "reconcile stuck queued → unavailable" do
# A contact stuck at :queued for > 3 days is one the cron has already
# tried ~144 times without the underlying worker landing any data. At that
# point the data simply doesn't exist in the upstream archive (dead ASOS
# station, pre-2014 HRRR, out-of-grid IEMRE), so we stop retrying and flip
# it to :unavailable.
@stale_cutoff 3 * 86_400
defp stale_ts do
DateTime.utc_now()
|> DateTime.add(-(@stale_cutoff + 3600), :second)
|> DateTime.truncate(:second)
end
defp fresh_ts do
DateTime.utc_now()
|> DateTime.add(-3600, :second)
|> DateTime.truncate(:second)
end
# Set the status field AND updated_at directly via update_all so Ecto
# timestamps can't overwrite what we put there. Also short-circuits the
# changeset, which ignores the *_status fields entirely.
defp force_status(contact, field, status, ts) do
import Ecto.Query
Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: [{field, status}, {:updated_at, ts}])
end
# Complete the OTHER three statuses so the contact no longer matches the
# enqueue filter on them — only the one under test remains :queued. That
# keeps the main enqueue loop from running `mark_status!` on the contact
# during the same perform/1 call, which would bump updated_at back to now.
defp only_this_stuck(contact, field) do
import Ecto.Query
other_fields = [:weather_status, :hrrr_status, :iemre_status, :terrain_status] -- [field]
sets = Enum.map(other_fields, &{&1, :complete})
Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: sets)
end
test "flips weather_status :queued → :unavailable when updated_at is older than the cutoff" do
contact = create_contact()
only_this_stuck(contact, :weather_status)
force_status(contact, :weather_status, :queued, stale_ts())
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert %Contact{weather_status: :unavailable} = Repo.get!(Contact, contact.id)
end
test "flips hrrr_status :queued → :unavailable when updated_at is older than the cutoff" do
contact = create_contact()
only_this_stuck(contact, :hrrr_status)
force_status(contact, :hrrr_status, :queued, stale_ts())
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert %Contact{hrrr_status: :unavailable} = Repo.get!(Contact, contact.id)
end
test "flips iemre_status :queued → :unavailable when updated_at is older than the cutoff" do
contact = create_contact()
only_this_stuck(contact, :iemre_status)
force_status(contact, :iemre_status, :queued, stale_ts())
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert %Contact{iemre_status: :unavailable} = Repo.get!(Contact, contact.id)
end
test "does NOT flip fresh :queued contacts to :unavailable" do
contact = create_contact()
only_this_stuck(contact, :weather_status)
force_status(contact, :weather_status, :queued, fresh_ts())
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
# Whatever the main enqueue loop does next (mark :queued, :complete,
# etc), reconcile_stale_queued_to_unavailable must NOT have fired: a
# contact fresher than the cutoff still has a chance at real data.
reloaded = Repo.get!(Contact, contact.id)
refute reloaded.weather_status == :unavailable
end
test "only reconciles the types passed in the args — doesn't touch unrelated statuses" do
# weather is stuck-queued-stale, but the cron is running with only hrrr
# in its types — weather_status should be left alone.
contact = create_contact()
only_this_stuck(contact, :weather_status)
force_status(contact, :weather_status, :queued, stale_ts())
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => ["hrrr"]}
})
assert %Contact{weather_status: :queued} = Repo.get!(Contact, contact.id)
end
end
end