prop/lib/microwaveprop/workers/contact_position_backfill_worker.ex
Graham McIntire 01d554f2e7
Hourly ContactPositionBackfillWorker fills null pos1/pos2
Safety net for rows that land via direct DB writes (manual fixes, bulk
imports) and bypass Radio.create_contact's grid-resolution requirement.
Runs every hour on the :admin queue with a 500-row per-invocation cap.
Fills whichever side resolves — if one grid is invalid, the other still
gets populated; distance_km is only written when both positions end up
set.
2026-04-16 12:22:34 -05:00

105 lines
2.9 KiB
Elixir

defmodule Microwaveprop.Workers.ContactPositionBackfillWorker do
@moduledoc """
Hourly safety net that fills in `pos1` / `pos2` / `distance_km` for contacts
whose grids resolve but whose positions are null.
New contacts always have positions because `Radio.create_contact/1` refuses
to insert without resolvable grids. This worker exists for rows that arrive
via direct DB writes (bulk imports, manual fixes) which bypass that path.
"""
use Oban.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
import Ecto.Query
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
require Logger
@default_limit 500
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
limit = Map.get(args, "limit", @default_limit)
candidates =
Contact
|> where([c], (is_nil(c.pos1) and not is_nil(c.grid1)) or (is_nil(c.pos2) and not is_nil(c.grid2)))
|> limit(^limit)
|> Repo.all()
count =
Enum.count(candidates, fn contact ->
updates = compute_updates(contact)
if updates == [] do
false
else
{n, _} =
Contact
|> where(id: ^contact.id)
|> Repo.update_all(set: updates)
n == 1
end
end)
if count > 0 do
Logger.info("ContactPositionBackfill: populated positions for #{count} contacts")
end
{:ok, %{updated: count}}
end
defp compute_updates(contact) do
pos1 = maybe_pos(contact.pos1, contact.grid1)
pos2 = maybe_pos(contact.pos2, contact.grid2)
[]
|> add_pos_change(:pos1, contact.pos1, pos1)
|> add_pos_change(:pos2, contact.pos2, pos2)
|> add_distance_change(contact, pos1, pos2)
|> maybe_touch_updated_at()
end
defp maybe_pos(existing, _grid) when not is_nil(existing), do: existing
defp maybe_pos(_nil, nil), do: nil
defp maybe_pos(_nil, grid) do
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon}
:error -> nil
end
end
defp add_pos_change(updates, _field, existing, _new) when not is_nil(existing), do: updates
defp add_pos_change(updates, _field, _existing, nil), do: updates
defp add_pos_change(updates, field, _nil, new), do: [{field, new} | updates]
defp add_distance_change(updates, contact, pos1, pos2) do
cond do
pos1 == nil or pos2 == nil ->
updates
not is_nil(contact.distance_km) and contact.pos1 != nil and contact.pos2 != nil ->
updates
true ->
distance =
pos1["lat"]
|> Radio.haversine_km(pos1["lon"], pos2["lat"], pos2["lon"])
|> round()
|> Decimal.new()
[{:distance_km, distance} | updates]
end
end
defp maybe_touch_updated_at([]), do: []
defp maybe_touch_updated_at(updates) do
[{:updated_at, DateTime.truncate(DateTime.utc_now(), :second)} | updates]
end
end