feat(rover-planning): hourly backfill worker recovers stuck paths

Adds RoverPlanning.backfill_paths/0 that walks every mission and
re-enqueues RoverPathProfileWorker jobs for any (rover × station) pair
whose Path is missing or not :complete. The path-profile worker
short-circuits on :complete, so this is idempotent.

Wires a new RoverMissionBackfillWorker into both dev (config.exs) and
prod (runtime.exs) crons at HH:30 — heals missions stuck in :pending
or :failed from transient elevation-API errors or interrupted
create_mission enqueues without operator action.
This commit is contained in:
Graham McIntire 2026-05-03 13:52:27 -05:00
parent e84f28643f
commit c2a23d1840
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 143 additions and 0 deletions

View file

@ -105,6 +105,9 @@ config :microwaveprop, Oban,
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}, {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker}, {"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker},
{"15 * * * *", Microwaveprop.Workers.GridCachePruneWorker}, {"15 * * * *", Microwaveprop.Workers.GridCachePruneWorker},
# Heals rover-planning missions whose paths are stuck in :pending
# or :failed (transient elevation-API errors, interrupted enqueues).
{"30 * * * *", Microwaveprop.Workers.RoverMissionBackfillWorker},
# UWYO publishes the 00Z/12Z Canadian radiosondes ~90 minutes after launch # UWYO publishes the 00Z/12Z Canadian radiosondes ~90 minutes after launch
{"30 1 * * *", CanadianSoundingFetchWorker}, {"30 1 * * *", CanadianSoundingFetchWorker},
{"30 13 * * *", CanadianSoundingFetchWorker}, {"30 13 * * *", CanadianSoundingFetchWorker},

View file

@ -267,6 +267,9 @@ if config_env() == :prod do
{"30 5,11,17,23 * * *", Microwaveprop.Workers.GefsFetchWorker}, {"30 5,11,17,23 * * *", Microwaveprop.Workers.GefsFetchWorker},
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker}, {"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker},
{"15 * * * *", Microwaveprop.Workers.GridCachePruneWorker}, {"15 * * * *", Microwaveprop.Workers.GridCachePruneWorker},
# Re-enqueues any rover-planning Path stuck in :pending/:failed.
# Idempotent — RoverPathProfileWorker no-ops on :complete.
{"30 * * * *", Microwaveprop.Workers.RoverMissionBackfillWorker},
# The :narr type is virtual: it targets contacts with hrrr_status = # The :narr type is virtual: it targets contacts with hrrr_status =
# :unavailable (pre-2014, missing from the HRRR archive) and dispatches # :unavailable (pre-2014, missing from the HRRR archive) and dispatches
# NarrFetchWorker against NCEI. See narr_jobs_for_contact/1. # NarrFetchWorker against NCEI. See narr_jobs_for_contact/1.

View file

@ -122,6 +122,26 @@ defmodule Microwaveprop.RoverPlanning do
enqueue_paths_for(mission) enqueue_paths_for(mission)
end end
@doc """
Walks every mission and re-enqueues any (rover_location × station)
pair whose `Path` is missing or not `:complete`. Existing complete
rows are left alone the worker also short-circuits on `:complete`,
so this is idempotent and safe to run on a cron.
Use case: a rolling deploy or a transient elevation-API failure left
some missions with `:pending` / `:failed` entries; the next backfill
tick recovers them without operator action.
"""
@spec backfill_paths() :: :ok
def backfill_paths do
Mission
|> Repo.all()
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|> Enum.each(&enqueue_paths_for/1)
:ok
end
defp enqueue_paths_for(%Mission{} = mission) do defp enqueue_paths_for(%Mission{} = mission) do
rovers = candidate_rover_locations(mission) rovers = candidate_rover_locations(mission)
stations = mission.stations || [] stations = mission.stations || []

View file

@ -0,0 +1,26 @@
defmodule Microwaveprop.Workers.RoverMissionBackfillWorker do
@moduledoc """
Periodic safety net for the `/rover-planning` matrix. Walks every
mission and re-enqueues `RoverPathProfileWorker` jobs for any
`(rover_location × station)` pair whose `Path` is missing or not
`:complete`. The path-profile worker short-circuits on `:complete`,
so this is idempotent across runs.
Recovers from transient failures (elevation API blips, rolling
deploys that interrupted the original `create_mission` enqueue)
without operator action.
"""
use Oban.Worker,
queue: :terrain,
priority: 5,
max_attempts: 3,
unique: [period: 300, states: [:available, :scheduled, :executing, :retryable]]
alias Microwaveprop.RoverPlanning
@impl Oban.Worker
def perform(%Oban.Job{}) do
RoverPlanning.backfill_paths()
end
end

View file

@ -84,6 +84,47 @@ defmodule Microwaveprop.RoverPlanningTest do
assert Map.has_key?(path.result, "propagation_score") assert Map.has_key?(path.result, "propagation_score")
end end
test "backfill_paths/0 re-runs failed paths across all missions" do
user = AccountsFixtures.user_fixture()
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
[path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
# Simulate a path that was stuck in :failed (e.g. transient
# elevation API error during the original computation).
{:ok, _} =
path
|> Ecto.Changeset.change(%{status: :failed, error: "transient", result: nil})
|> Repo.update()
assert :ok = RoverPlanning.backfill_paths()
# Oban inline → backfill enqueues a job, the worker reruns
# immediately, and the path flips back to :complete.
[reloaded] = RoverPlanning.list_paths(mission)
assert reloaded.status == :complete
assert is_map(reloaded.result)
end
test "backfill_paths/0 inserts missing rows for under-populated missions" do
user = AccountsFixtures.user_fixture()
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
# Simulate a half-broken mission: paths were never persisted (e.g.
# the original create_mission Multi succeeded but the Oban insert
# was lost due to a crash). Backfill must repopulate the matrix.
Repo.delete_all(Ecto.Query.from(p in RoverPlanning.Path, where: p.mission_id == ^mission.id))
assert RoverPlanning.list_paths(mission) == []
assert :ok = RoverPlanning.backfill_paths()
assert [path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
end
test "with only_known_good=false, all rover-locations get a path entry" do test "with only_known_good=false, all rover-locations get a path entry" do
user = AccountsFixtures.user_fixture() user = AccountsFixtures.user_fixture()
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) _ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

View file

@ -0,0 +1,50 @@
defmodule Microwaveprop.Workers.RoverMissionBackfillWorkerTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Workers.RoverMissionBackfillWorker
setup do
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
describe "perform/1" do
test "re-runs failed paths" do
user = AccountsFixtures.user_fixture()
{:ok, _loc} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} =
RoverPlanning.create_mission(user, %{
"name" => "Backfill mission",
"band_mhz" => 10_000,
"only_known_good" => true,
"rover_height_ft" => 8.0,
"station_height_ft" => 30.0,
"stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
})
[path] = RoverPlanning.list_paths(mission)
assert path.status == :complete
{:ok, _} =
path
|> Ecto.Changeset.change(%{status: :failed, error: "transient", result: nil})
|> Repo.update()
assert :ok = RoverMissionBackfillWorker.perform(%Oban.Job{})
[reloaded] = RoverPlanning.list_paths(mission)
assert reloaded.status == :complete
end
end
end