prop/lib/microwaveprop/workers/radar_frame_worker.ex
Graham McIntire ddedd241de
fix(radar): dedupe RadarFrameWorker jobs by frame_ts
BackfillEnqueueWorker re-enqueued every :queued contact each cycle, and
RadarFrameWorker had no unique clause, so the same 5-min NEXRAD frame
got scheduled repeatedly with overlapping contact lists. Prod had 9,134
duplicate available jobs across 1,061 distinct frames — 89% waste.

Two coupled changes so contacts aren't stranded by the dedup:

  1. Worker now queries eligible contacts by the frame's 5-min bucket
     instead of the args' contact_ids list. A contact that flips to
     :queued between the enqueue and the job running gets picked up.

  2. unique: [frame_ts] on states [available, scheduled, retryable].
     :executing intentionally excluded — a contact that flips to :queued
     mid-execution can still enqueue a follow-up instead of getting
     stuck behind a running job that missed it.
2026-04-20 14:21:55 -05:00

132 lines
4.7 KiB
Elixir

defmodule Microwaveprop.Workers.RadarFrameWorker do
@moduledoc """
Batched NEXRAD common-volume radar enrichment.
Each job processes every contact whose QSO timestamp rounds to the
same 5-minute NEXRAD frame — fetch + decode happens *once* per job
and the decoded pixel buffer is walked per contact. This collapses
the 1-job-per-contact backfill pattern (`CommonVolumeRadarWorker`)
down by roughly 10x at current backlog depth (17k contacts / 1.7k
distinct frames).
`CommonVolumeRadarWorker` stays around for the submit-time single-
contact path where batching is pointless. Both workers end in the
same state transitions so they can coexist safely — a contact
marked `:complete` by one is a no-op for the other.
"""
# unique on frame_ts: backfill sweeps that re-select the same still-:queued
# contacts each cycle would otherwise enqueue a fresh 1-job-per-contact
# frame fetch. 9k+ wasteful duplicates observed in prod before this guard.
# :executing deliberately excluded so a contact that flips to :queued mid-
# execution can enqueue a follow-up job rather than getting stuck.
use Oban.Worker,
queue: :radar,
max_attempts: 3,
unique: [fields: [:args], keys: [:frame_ts], states: [:available, :scheduled, :retryable], period: :infinity]
import Ecto.Query
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactCommonVolumeRadar
alias Microwaveprop.Repo
alias Microwaveprop.Weather.NexradClient
alias Microwaveprop.Workers.CommonVolumeRadarWorker
require Logger
@terminal_statuses [:complete, :unavailable]
@frame_window_seconds 5 * 60
@impl Oban.Worker
def perform(%Oban.Job{args: %{"frame_ts" => frame_iso}}) do
{:ok, frame_ts, _} = DateTime.from_iso8601(frame_iso)
rounded = NexradClient.round_to_5min(frame_ts)
bucket_end = DateTime.add(rounded, @frame_window_seconds, :second)
# Query by the frame's 5-min bucket rather than a passed contact_ids
# list. The unique-on-frame_ts guard prevents new sweeps from enqueueing
# duplicate jobs, which means a contact that flips to :queued after the
# job is enqueued won't land in a separate job's contact_ids — it has to
# be picked up by the already-queued frame job instead.
contacts =
Contact
|> where([c], c.qso_timestamp >= ^rounded and c.qso_timestamp < ^bucket_end)
|> where([c], c.radar_status not in ^@terminal_statuses)
|> Repo.all()
case contacts do
[] ->
:ok
contacts ->
run_batch(contacts, rounded)
end
end
defp run_batch(contacts, rounded) do
case NexradClient.fetch_decoded_frame(rounded) do
{:ok, pixels, width} ->
process_frame(contacts, rounded, pixels, width)
{:error, reason} ->
Enum.each(contacts, &mark_status(&1, :unavailable))
Logger.info(
"RadarFrameWorker: no frame for #{DateTime.to_iso8601(rounded)} (#{length(contacts)} contacts): #{inspect(reason)}"
)
end
:ok
end
@doc """
Process a batch of contacts against an already-decoded frame. Exposed
for tests and for any caller that has the pixel buffer in hand.
"""
@spec process_frame([Contact.t()], DateTime.t(), binary(), pos_integer()) :: :ok
def process_frame(contacts, rounded, pixels, width) do
Enum.each(contacts, fn contact ->
process_contact(contact, pixels, width, rounded)
end)
Logger.info("RadarFrameWorker: #{length(contacts)} contacts ingested for frame #{DateTime.to_iso8601(rounded)}")
:ok
end
defp process_contact(%Contact{pos1: p1, pos2: p2} = contact, pixels, width, rounded) when is_map(p1) and is_map(p2) do
pos1 = to_latlon(p1)
pos2 = to_latlon(p2)
stats = CommonVolumeRadarWorker.aggregate_stats(pixels, width, pos1, pos2)
upsert_row!(contact, rounded, stats)
mark_status(contact, :complete)
end
defp process_contact(contact, _pixels, _width, _rounded) do
# Positions missing — can't compute a common volume, but we got
# this far because the enqueue filter thought it was enrichable.
# Mark unavailable so the backfill cron stops re-queueing it.
mark_status(contact, :unavailable)
end
defp upsert_row!(%Contact{id: contact_id}, observed_at, stats) do
%ContactCommonVolumeRadar{}
|> ContactCommonVolumeRadar.changeset(Map.merge(stats, %{contact_id: contact_id, observed_at: observed_at}))
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :contact_id, :inserted_at]},
conflict_target: :contact_id
)
end
defp mark_status(%Contact{} = contact, status) do
contact
|> Ecto.Changeset.change(%{radar_status: status})
|> Repo.update!()
end
defp to_latlon(%{"lat" => lat, "lon" => lon}) when is_number(lat) and is_number(lon) do
{lat / 1.0, lon / 1.0}
end
end