prop/lib/microwaveprop/weather/hrrr_point_enqueuer.ex
Graham McIntire 7416583c27
feat(hrrr): route per-QSO enrichment through hrrr_fetch_tasks (Stream C Elixir)
Phase 3 Stream C Elixir-side: HrrrFetchWorker is deleted; per-QSO HRRR
enrichment now writes to the new hrrr_fetch_tasks table for the Rust
hrrr-point-worker to drain.

Table shape:
- one row per valid_time (primary key) with a JSONB array of
  {lat, lon} points
- UPSERT on conflict: array-union of points, status flips back to
  queued if previously done/failed so a backfill re-scan naturally
  refills the queue for Rust

Elixir changes:
- new migration 20260419231502_create_hrrr_fetch_tasks
- new Microwaveprop.Weather.HrrrPointEnqueuer with enqueue/1 and
  enqueue_for_contacts/1. Pre-2014 contacts (NARR's territory)
  are skipped here so hrrr_status can pin them to :unavailable.
- ContactWeatherEnqueueWorker: build_hrrr_jobs/1 removed; single-
  contact path and batch perform/1 both route through
  HrrrPointEnqueuer.enqueue_for_contacts/1. A placeholder jobs-list
  is kept just to feed mark_hrrr_status!.
- contact_live/show.ex retry button enqueues via the same path.
- :hrrr queue removed from dev/config/runtime.exs
- HrrrFetchWorker module + test deleted

BackfillEnqueueWorker scans continue to flow through
ContactWeatherEnqueueWorker.enqueue_for_contact (unchanged), so the
30-min reconcile refills hrrr_fetch_tasks automatically.

4 new tests cover the routing, pre-2014 skip, UPSERT-union, and
status-reset-on-reschedule behaviour. Rust-side hrrr-point-worker
binary + k8s deployment land in the next commits.
2026-04-19 18:22:22 -05:00

150 lines
5 KiB
Elixir

defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
@moduledoc """
Inserts rows into `hrrr_fetch_tasks` for the Rust hrrr-point-worker
to drain.
Called from `Microwaveprop.Workers.ContactWeatherEnqueueWorker` in
place of the legacy `HrrrFetchWorker.new/1` fan-out. One row per
`valid_time` with a JSONB array of `{lat, lon}` points that share
that hour — additional points arriving from a later backfill tick
union into the existing row rather than creating a second one, so
the Rust worker fetches each GRIB2 once regardless of how many
contacts feed it.
`BackfillEnqueueWorker` re-discovers contacts missing HRRR enrichment
every 30 minutes; each scan flows through `enqueue_for_contact` and
lands here, which keeps the backfill pipeline intact after the
cutover.
"""
import Ecto.Query
alias Microwaveprop.Repo
require Logger
@doc """
Enqueue `valid_time -> [{lat, lon}, ...]` groups into
`hrrr_fetch_tasks`. On conflict the incoming points are array-unioned
into the existing row and the status is reset to `queued` so a
previously-done valid_time re-scheduled by backfill picks up the new
points without duplicating a fetch.
"""
@spec enqueue(%{DateTime.t() => [{float(), float()}]}) :: {:ok, non_neg_integer()}
def enqueue(groups) when is_map(groups) do
now = DateTime.truncate(DateTime.utc_now(), :microsecond)
count =
Enum.reduce(groups, 0, fn {valid_time, points}, acc ->
valid_time = DateTime.truncate(valid_time, :second)
json_points = Enum.map(points, fn {lat, lon} -> %{"lat" => lat, "lon" => lon} end)
# Union with the existing row's points (JSONB array) — use a
# subquery with jsonb_array_elements to dedupe. Postgres
# handles the set math so the Elixir side stays simple.
#
# Insert uses a wrapping schema query so Postgrex's jsonb
# extension encodes the list directly; raw Repo.query! with
# a binding would fall through to `text->jsonb` cast which
# then stores the JSON as a jsonb *string* and breaks the
# || union on round-trip.
id = Ecto.UUID.bingenerate()
Repo.insert_all(
"hrrr_fetch_tasks",
[
%{
id: id,
valid_time: valid_time,
points: json_points,
status: "queued",
attempt: 0,
inserted_at: now,
updated_at: now
}
],
on_conflict:
from(t in "hrrr_fetch_tasks",
update: [
set: [
points:
fragment(
"(SELECT COALESCE(jsonb_agg(DISTINCT p), '[]'::jsonb) FROM jsonb_array_elements(? || EXCLUDED.points) AS p)",
t.points
),
status:
fragment(
"CASE WHEN ? IN ('done', 'failed') THEN 'queued' ELSE ? END",
t.status,
t.status
),
attempt:
fragment(
"CASE WHEN ? IN ('done', 'failed') THEN 0 ELSE ? END",
t.status,
t.attempt
),
updated_at: ^now
]
]
),
conflict_target: [:valid_time]
)
acc + 1
end)
{:ok, count}
rescue
e ->
Logger.error("HrrrPointEnqueuer: enqueue failed: #{inspect(e)}")
{:ok, 0}
end
@doc """
Convenience: given a list of `Contact`s, extract the
`(valid_time, [lat, lon])` groups and enqueue in one call. Mirrors
the signature of the removed `ContactWeatherEnqueueWorker.build_hrrr_jobs/1`
so the caller swap is a one-liner.
"""
@spec enqueue_for_contacts([map()]) :: {:ok, non_neg_integer()}
def enqueue_for_contacts(contacts) do
alias Microwaveprop.Radio
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.NarrClient
groups =
contacts
|> Enum.flat_map(fn contact ->
cond do
is_nil(contact.pos1) ->
[]
# HRRR archive starts mid-2014; NARR covers the pre-2014
# window. Pre-coverage contacts are NARR's responsibility.
NarrClient.in_coverage?(contact.qso_timestamp) ->
[]
true ->
rounded_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
contact
|> Radio.contact_path_points()
|> Enum.flat_map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
if Weather.has_hrrr_profile?(rlat, rlon, rounded_time) do
[]
else
[{rounded_time, {rlat, rlon}}]
end
end)
end
end)
|> Enum.group_by(fn {time, _pt} -> time end, fn {_time, pt} -> pt end)
|> Map.new(fn {time, pts} -> {time, Enum.uniq(pts)} end)
enqueue(groups)
end
end