Add type filter to backfill enqueue

Backfill dashboard now has checkboxes to select which enrichment types
to process (HRRR, Weather, Terrain, IEMRE). Prevents weather jobs from
flooding the queue when only HRRR needs work.
This commit is contained in:
Graham McIntire 2026-04-06 11:42:20 -05:00
parent ca6e836bd9
commit e99e585b66
9 changed files with 93 additions and 53 deletions

View file

@ -46,6 +46,20 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com" host = System.get_env("PHX_HOST") || "example.com"
config :libcluster,
topologies: [
k8s: [
strategy: Cluster.Strategy.Kubernetes,
config: [
mode: :ip,
kubernetes_ip_lookup_mode: :pods,
kubernetes_node_basename: "microwaveprop",
kubernetes_selector: "app=prop",
kubernetes_namespace: "prop"
]
]
]
# ## SSL Support # ## SSL Support
# #
# To get SSL working, you will need to add the `https` key # To get SSL working, you will need to add the `https` key
@ -130,20 +144,6 @@ if config_env() == :prod do
]} ]}
] ]
config :libcluster,
topologies: [
k8s: [
strategy: Cluster.Strategy.Kubernetes,
config: [
mode: :ip,
kubernetes_ip_lookup_mode: :pods,
kubernetes_node_basename: "microwaveprop",
kubernetes_selector: "app=prop",
kubernetes_namespace: "prop"
]
]
]
config :microwaveprop, :email_from, {"NTMS Propagation", "graham@w5isp.com"} config :microwaveprop, :email_from, {"NTMS Propagation", "graham@w5isp.com"}
config :microwaveprop, hrrr_base_url: System.get_env("HRRR_BASE_URL", "http://skippy.w5isp.com:8080") config :microwaveprop, hrrr_base_url: System.get_env("HRRR_BASE_URL", "http://skippy.w5isp.com:8080")
config :microwaveprop, srtm_tiles_dir: "/srtm" config :microwaveprop, srtm_tiles_dir: "/srtm"

View file

@ -14,25 +14,24 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
require Logger require Logger
@enrichable [:pending, :queued, :failed] @enrichable [:pending, :queued, :failed]
@valid_types ~w(hrrr weather terrain iemre)
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"limit" => limit}}) do def perform(%Oban.Job{args: %{"limit" => limit} = args}) do
types = parse_types(args)
contacts = contacts =
Repo.all( Contact
from(c in Contact, |> where([c], not is_nil(c.pos1) or not is_nil(c.grid1))
where: |> where(^type_filter(types))
(not is_nil(c.pos1) or not is_nil(c.grid1)) and |> order_by([c], desc: c.qso_timestamp)
(c.hrrr_status in ^@enrichable or c.weather_status in ^@enrichable or |> limit(^limit)
c.terrain_status in ^@enrichable or c.iemre_status in ^@enrichable), |> Repo.all()
order_by: [desc: c.qso_timestamp],
limit: ^limit
)
)
count = length(contacts) count = length(contacts)
Logger.info("BackfillEnqueue: processing #{count} contacts") Logger.info("BackfillEnqueue: processing #{count} contacts (types: #{inspect(types)})")
Enum.each(contacts, &ContactWeatherEnqueueWorker.enqueue_for_contact/1) Enum.each(contacts, &ContactWeatherEnqueueWorker.enqueue_for_contact(&1, types))
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
Microwaveprop.PubSub, Microwaveprop.PubSub,
@ -42,4 +41,17 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
:ok :ok
end end
defp parse_types(%{"types" => types}) when is_list(types) do
types |> Enum.filter(&(&1 in @valid_types)) |> Enum.map(&String.to_existing_atom/1)
end
defp parse_types(_), do: [:hrrr, :weather, :terrain, :iemre]
defp type_filter(types) do
Enum.reduce(types, dynamic(false), fn type, acc ->
field = :"#{type}_status"
dynamic([c], ^acc or field(c, ^field) in ^@enrichable)
end)
end
end end

View file

@ -18,13 +18,14 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
@doc """ @doc """
Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single contact. Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single contact.
Called directly from submission flow no Oban indirection. Called directly from submission flow no Oban indirection.
Optionally pass a list of types to limit which enrichments run.
""" """
def enqueue_for_contact(contact) do def enqueue_for_contact(contact, types \\ [:hrrr, :weather, :terrain, :iemre]) do
contact = Radio.ensure_positions!(contact) contact = Radio.ensure_positions!(contact)
weather_jobs = build_weather_jobs([contact]) weather_jobs = if :weather in types, do: build_weather_jobs([contact]), else: []
hrrr_jobs = build_hrrr_jobs([contact]) hrrr_jobs = if :hrrr in types, do: build_hrrr_jobs([contact]), else: []
terrain_jobs = build_terrain_jobs([contact]) terrain_jobs = if :terrain in types, do: build_terrain_jobs([contact]), else: []
iemre_jobs = build_iemre_jobs([contact]) iemre_jobs = if :iemre in types, do: build_iemre_jobs([contact]), else: []
all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs
@ -35,13 +36,13 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
ids = [contact.id] ids = [contact.id]
if contact.pos1 do if contact.pos1 do
mark_status!(ids, :weather_status, weather_jobs) if :weather in types, do: mark_status!(ids, :weather_status, weather_jobs)
mark_status!(ids, :hrrr_status, hrrr_jobs) if :hrrr in types, do: mark_status!(ids, :hrrr_status, hrrr_jobs)
mark_status!(ids, :iemre_status, iemre_jobs) if :iemre in types, do: mark_status!(ids, :iemre_status, iemre_jobs)
end end
if contact.pos1 && contact.pos2 do if contact.pos1 && contact.pos2 do
mark_status!(ids, :terrain_status, terrain_jobs) if :terrain in types, do: mark_status!(ids, :terrain_status, terrain_jobs)
end end
:ok :ok

View file

@ -5,14 +5,14 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do
max_attempts: 20, max_attempts: 20,
unique: [period: 300, states: [:available, :scheduled, :executing, :retryable]] unique: [period: 300, states: [:available, :scheduled, :executing, :retryable]]
require Logger
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Weather alias Microwaveprop.Weather
alias Microwaveprop.Weather.IemClient alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.SoundingParams alias Microwaveprop.Weather.SoundingParams
alias Microwaveprop.Weather.Station alias Microwaveprop.Weather.Station
require Logger
@impl Oban.Worker @impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do def backoff(%Oban.Job{attempt: attempt}) do
min(120 * Integer.pow(2, attempt - 1), _six_hours = 21_600) min(120 * Integer.pow(2, attempt - 1), _six_hours = 21_600)

View file

@ -41,10 +41,18 @@ defmodule MicrowavepropWeb.BackfillLive do
end end
@impl true @impl true
def handle_event("enqueue", %{"limit" => limit_str}, socket) do def handle_event("enqueue", %{"limit" => limit_str} = params, socket) do
limit = String.to_integer(limit_str) limit = String.to_integer(limit_str)
%{"limit" => limit} types =
~w(hrrr weather terrain iemre)
|> Enum.filter(&(params[&1] == "true"))
|> then(fn
[] -> ~w(hrrr weather terrain iemre)
types -> types
end)
%{"limit" => limit, "types" => types}
|> BackfillEnqueueWorker.new() |> BackfillEnqueueWorker.new()
|> Oban.insert() |> Oban.insert()
@ -295,7 +303,8 @@ defmodule MicrowavepropWeb.BackfillLive do
</div> </div>
<div class="flex flex-col gap-1.5 mt-3"> <div class="flex flex-col gap-1.5 mt-3">
<%= for {label, complete} <- [{"HRRR", @unprocessed.total - @unprocessed.hrrr}, {"Weather", @unprocessed.total - @unprocessed.weather}, {"Terrain", @unprocessed.total - @unprocessed.terrain}, {"IEMRE", @unprocessed.total - @unprocessed.iemre}] do %> <%= for {label, complete} <- [{"HRRR", @unprocessed.total - @unprocessed.hrrr}, {"Weather", @unprocessed.total - @unprocessed.weather}, {"Terrain", @unprocessed.total - @unprocessed.terrain}, {"IEMRE", @unprocessed.total - @unprocessed.iemre}] do %>
<% pct = if @unprocessed.total > 0, do: round(complete * 100 / @unprocessed.total), else: 0 %> <% pct =
if @unprocessed.total > 0, do: round(complete * 100 / @unprocessed.total), else: 0 %>
<div class="flex items-center gap-2 text-xs"> <div class="flex items-center gap-2 text-xs">
<span class="w-14 shrink-0">{label}</span> <span class="w-14 shrink-0">{label}</span>
<progress <progress
@ -327,7 +336,7 @@ defmodule MicrowavepropWeb.BackfillLive do
</div> </div>
<div class="bg-base-200 rounded-box p-4 mb-6"> <div class="bg-base-200 rounded-box p-4 mb-6">
<form phx-submit="enqueue" class="flex items-end gap-4"> <form phx-submit="enqueue" class="flex items-end gap-4 flex-wrap">
<div> <div>
<label class="label text-sm">Contacts to enqueue</label> <label class="label text-sm">Contacts to enqueue</label>
<input <input
@ -339,6 +348,14 @@ defmodule MicrowavepropWeb.BackfillLive do
class="input input-bordered w-32" class="input input-bordered w-32"
/> />
</div> </div>
<div class="flex gap-3 items-center">
<%= for type <- ~w(hrrr weather terrain iemre) do %>
<label class="label cursor-pointer gap-1">
<input type="checkbox" name={type} value="true" checked class="checkbox checkbox-sm" />
<span class="text-xs uppercase">{type}</span>
</label>
<% end %>
</div>
<button class="btn btn-primary" disabled={@enqueuing}> <button class="btn btn-primary" disabled={@enqueuing}>
<%= if @enqueuing do %> <%= if @enqueuing do %>
<span class="loading loading-spinner loading-sm"></span> Enqueuing... <span class="loading loading-spinner loading-sm"></span> Enqueuing...

View file

@ -1,6 +1,8 @@
defmodule MicrowavepropWeb.Router do defmodule MicrowavepropWeb.Router do
use MicrowavepropWeb, :router use MicrowavepropWeb, :router
import Phoenix.LiveDashboard.Router
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug :accepts, ["html"]
plug :fetch_session plug :fetch_session
@ -47,8 +49,6 @@ defmodule MicrowavepropWeb.Router do
# pipe_through :api # pipe_through :api
# end # end
import Phoenix.LiveDashboard.Router
scope "/" do scope "/" do
pipe_through :browser pipe_through :browser

3
tail_logs.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/bash
stern -n prop -l app=prop

View file

@ -49,7 +49,7 @@ defmodule Microwaveprop.Workers.SolarIndexWorkerTest do
describe "perform/1 with date arg" do describe "perform/1 with date arg" do
test "fetches and upserts only the matching date" do test "fetches and upserts only the matching date" do
today = Date.utc_today() |> Date.to_iso8601() today = Date.to_iso8601(Date.utc_today())
Req.Test.stub(SolarClient, fn conn -> Req.Test.stub(SolarClient, fn conn ->
Req.Test.text(conn, gfz_body_with_recent_dates()) Req.Test.text(conn, gfz_body_with_recent_dates())
@ -64,7 +64,7 @@ defmodule Microwaveprop.Workers.SolarIndexWorkerTest do
Plug.Conn.send_resp(conn, 500, "Internal Server Error") Plug.Conn.send_resp(conn, 500, "Internal Server Error")
end) end)
today = Date.utc_today() |> Date.to_iso8601() today = Date.to_iso8601(Date.utc_today())
assert {:error, "HTTP 500"} = SolarIndexWorker.perform(%Oban.Job{args: %{"date" => today}}) assert {:error, "HTTP 500"} = SolarIndexWorker.perform(%Oban.Job{args: %{"date" => today}})
end end
end end

View file

@ -13,7 +13,8 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do
describe "call/2" do describe "call/2" do
test "extracts IP from cf-connecting-ip header" do test "extracts IP from cf-connecting-ip header" do
conn = conn =
conn(:get, "/") :get
|> conn("/")
|> put_req_header("cf-connecting-ip", "1.2.3.4") |> put_req_header("cf-connecting-ip", "1.2.3.4")
|> call_plug() |> call_plug()
@ -22,7 +23,8 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do
test "falls back to x-forwarded-for when no cf-connecting-ip" do test "falls back to x-forwarded-for when no cf-connecting-ip" do
conn = conn =
conn(:get, "/") :get
|> conn("/")
|> put_req_header("x-forwarded-for", "5.6.7.8") |> put_req_header("x-forwarded-for", "5.6.7.8")
|> call_plug() |> call_plug()
@ -31,7 +33,8 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do
test "prefers cf-connecting-ip over x-forwarded-for" do test "prefers cf-connecting-ip over x-forwarded-for" do
conn = conn =
conn(:get, "/") :get
|> conn("/")
|> put_req_header("cf-connecting-ip", "1.2.3.4") |> put_req_header("cf-connecting-ip", "1.2.3.4")
|> put_req_header("x-forwarded-for", "5.6.7.8") |> put_req_header("x-forwarded-for", "5.6.7.8")
|> call_plug() |> call_plug()
@ -41,7 +44,8 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do
test "takes first IP from comma-separated x-forwarded-for" do test "takes first IP from comma-separated x-forwarded-for" do
conn = conn =
conn(:get, "/") :get
|> conn("/")
|> put_req_header("x-forwarded-for", "10.0.0.1, 10.0.0.2, 10.0.0.3") |> put_req_header("x-forwarded-for", "10.0.0.1, 10.0.0.2, 10.0.0.3")
|> call_plug() |> call_plug()
@ -52,7 +56,8 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do
original_ip = {127, 0, 0, 1} original_ip = {127, 0, 0, 1}
conn = conn =
conn(:get, "/") :get
|> conn("/")
|> Map.put(:remote_ip, original_ip) |> Map.put(:remote_ip, original_ip)
|> call_plug() |> call_plug()
@ -61,7 +66,8 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do
test "handles IPv6 addresses" do test "handles IPv6 addresses" do
conn = conn =
conn(:get, "/") :get
|> conn("/")
|> put_req_header("cf-connecting-ip", "::1") |> put_req_header("cf-connecting-ip", "::1")
|> call_plug() |> call_plug()
@ -72,7 +78,8 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do
original_ip = {127, 0, 0, 1} original_ip = {127, 0, 0, 1}
conn = conn =
conn(:get, "/") :get
|> conn("/")
|> Map.put(:remote_ip, original_ip) |> Map.put(:remote_ip, original_ip)
|> put_req_header("cf-connecting-ip", "not-an-ip") |> put_req_header("cf-connecting-ip", "not-an-ip")
|> call_plug() |> call_plug()