prop/lib/microwaveprop/rover_planning.ex
Graham McIntire c2a23d1840
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.
2026-05-03 13:52:27 -05:00

223 lines
6.4 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.RoverPlanning do
@moduledoc """
Context for the `/rover-planning` feature: planned rover missions
with stationary endpoints and per-rover-location terrain path
profiles. Mutations are scoped to a user (or admin); reads are
global.
After a successful `create_mission/2` or
`replace_mission_paths/1`, the context enqueues one
`RoverPathProfileWorker` job per (rover-location, station) pair
so the show page can render results as they land.
"""
import Ecto.Query
alias Ecto.Multi
alias Microwaveprop.Accounts.User
alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location
alias Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.RoverPlanning.Station
alias Microwaveprop.Workers.RoverPathProfileWorker
@spec list_missions() :: [Mission.t()]
def list_missions do
Mission
|> order_by([m], desc: m.inserted_at)
|> preload([:user, :stations])
|> Repo.all()
end
@spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil
def get_mission(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} ->
Mission
|> Repo.get(uuid)
|> Repo.preload([:user, stations: from(s in Station, order_by: s.position)])
:error ->
nil
end
end
@spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil
def get_mission_with_paths(id) do
case get_mission(id) do
nil -> nil
mission -> Repo.preload(mission, paths: [:rover_location, :station])
end
end
@spec create_mission(User.t(), map()) ::
{:ok, Mission.t()} | {:error, Ecto.Changeset.t()}
def create_mission(%User{} = user, attrs) do
changeset = Mission.changeset(%Mission{user_id: user.id}, attrs)
case Repo.insert(changeset) do
{:ok, mission} ->
mission = Repo.preload(mission, [:user, :stations])
:ok = enqueue_paths_for(mission)
{:ok, mission}
{:error, _} = error ->
error
end
end
@spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
{:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_mission(%User{} = user, id, attrs) do
case fetch_owned(user, id) do
{:ok, mission} ->
mission
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|> Mission.changeset(attrs)
|> Repo.update()
|> case do
{:ok, updated} ->
updated = Repo.preload(updated, [:user, :stations], force: true)
:ok = replace_mission_paths(updated)
{:ok, updated}
other ->
other
end
{:error, :not_found} ->
{:error, :not_found}
end
end
@spec delete_mission(User.t(), Ecto.UUID.t()) ::
{:ok, Mission.t()} | {:error, :not_found}
def delete_mission(%User{} = user, id) do
case fetch_owned(user, id) do
{:ok, mission} -> Repo.delete(mission)
{:error, :not_found} -> {:error, :not_found}
end
end
@spec list_paths(Mission.t()) :: [Path.t()]
def list_paths(%Mission{id: mission_id}) do
Repo.all(
from(p in Path,
where: p.mission_id == ^mission_id,
preload: [:rover_location, :station],
order_by: [asc: p.station_id, asc: p.rover_location_id]
)
)
end
@doc """
Wipes the existing path matrix for a mission and re-enqueues all
pairings. Called after a mission edit when the station list or
`only_known_good` toggle could have shifted the matrix.
"""
@spec replace_mission_paths(Mission.t()) :: :ok
def replace_mission_paths(%Mission{} = mission) do
Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id))
enqueue_paths_for(mission)
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
rovers = candidate_rover_locations(mission)
stations = mission.stations || []
pairs =
for rover <- rovers, station <- stations do
%{rover: rover, station: station}
end
case pairs do
[] -> :ok
_ -> insert_pending_paths_and_jobs(mission, pairs)
end
end
@doc """
Rover-locations the matrix actually scores against, given the
mission's `only_known_good` flag. Public so the show LiveView can
render the same list the worker enqueues against.
"""
@spec candidate_rover_locations(Mission.t()) :: [Location.t()]
def candidate_rover_locations(%Mission{only_known_good: true}) do
Repo.all(from(l in Location, where: l.status == :good, order_by: [desc: l.inserted_at]))
end
def candidate_rover_locations(%Mission{only_known_good: false}) do
Repo.all(from(l in Location, order_by: [desc: l.inserted_at]))
end
defp insert_pending_paths_and_jobs(mission, pairs) do
{:ok, _} =
pairs
|> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi ->
attrs = %{
mission_id: mission.id,
rover_location_id: r.id,
station_id: s.id,
status: :pending
}
Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs),
on_conflict: :nothing,
conflict_target: [:mission_id, :rover_location_id, :station_id]
)
end)
|> Repo.transaction()
enqueue_jobs(mission, pairs)
:ok
end
defp enqueue_jobs(mission, pairs) do
for %{rover: r, station: s} <- pairs do
%{
"mission_id" => mission.id,
"rover_location_id" => r.id,
"station_id" => s.id
}
|> RoverPathProfileWorker.new()
|> Oban.insert()
end
:ok
end
defp fetch_owned(%User{is_admin: true}, id) do
case get_mission(id) do
nil -> {:error, :not_found}
mission -> {:ok, mission}
end
end
defp fetch_owned(%User{id: user_id}, id) do
case get_mission(id) do
%Mission{user_id: ^user_id} = mission -> {:ok, mission}
_ -> {:error, :not_found}
end
end
end