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.
223 lines
6.4 KiB
Elixir
223 lines
6.4 KiB
Elixir
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
|