perf(rover-planning): create_mission returns immediately, batch path / Oban inserts

Two stacked wins for the form-submit hot path:

1) create_mission now hands path-matrix population to the same async
   reconcile worker update_mission uses, so the request returns
   right after the Mission row insert. For a mission with hundreds
   of (rover x station x band) tuples this used to block the LiveView
   for many seconds.

2) Inside reconcile/enqueue_paths_for, the per-pair Multi.insert
   loop is replaced with one Repo.insert_all for the Path rows and
   one Oban.insert_all for the worker jobs. N round-trips collapse
   to two regardless of pair count.
This commit is contained in:
Graham McIntire 2026-05-03 15:05:12 -05:00
parent a4f0e171e8
commit 3060eddfb2
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -13,7 +13,6 @@ defmodule Microwaveprop.RoverPlanning do
import Ecto.Query import Ecto.Query
alias Ecto.Multi
alias Microwaveprop.Accounts.User alias Microwaveprop.Accounts.User
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location alias Microwaveprop.Rover.Location
@ -60,7 +59,13 @@ defmodule Microwaveprop.RoverPlanning do
case Repo.insert(changeset) do case Repo.insert(changeset) do
{:ok, mission} -> {:ok, mission} ->
mission = Repo.preload(mission, [:user, :stations]) mission = Repo.preload(mission, [:user, :stations])
:ok = enqueue_paths_for(mission) # Async: a brand-new mission can have hundreds of (rover x
# station x band) tuples. Hand the matrix population off to
# the reconcile worker so the form-submit request returns
# immediately instead of blocking on N inserts + N Oban
# enqueues per pair. The worker calls reconcile_mission_paths,
# which is identical to enqueue_paths_for for an empty matrix.
:ok = enqueue_reconcile(mission)
{:ok, mission} {:ok, mission}
{:error, _} = error -> {:error, _} = error ->
@ -226,43 +231,48 @@ defmodule Microwaveprop.RoverPlanning do
defp insert_pending_paths_and_jobs([], _mission), do: :ok defp insert_pending_paths_and_jobs([], _mission), do: :ok
defp insert_pending_paths_and_jobs(tuples, mission) when is_list(tuples) do defp insert_pending_paths_and_jobs(tuples, mission) when is_list(tuples) do
{:ok, _} = # Batch the path-row inserts and the Oban-job inserts into a
tuples # single SQL statement each. The previous Multi-of-N-inserts +
|> Enum.reduce(Multi.new(), fn {rover_id, station_id, band_mhz}, multi -> # N x Oban.insert call took ~1.5s per pair × 900 pairs in the
attrs = %{ # worst-case mission — `insert_all` collapses both to one
# roundtrip per kind regardless of pair count.
now = DateTime.truncate(DateTime.utc_now(), :second)
path_rows =
Enum.map(tuples, fn {rover_id, station_id, band_mhz} ->
%{
id: Ecto.UUID.generate(),
mission_id: mission.id, mission_id: mission.id,
rover_location_id: rover_id, rover_location_id: rover_id,
station_id: station_id, station_id: station_id,
band_mhz: band_mhz, band_mhz: band_mhz,
status: :pending status: :pending,
inserted_at: now,
updated_at: now
} }
Multi.insert(
multi,
{:path, rover_id, station_id, band_mhz},
Path.changeset(%Path{}, attrs),
on_conflict: :nothing,
conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz]
)
end) end)
|> Repo.transaction()
Repo.insert_all(Path, path_rows,
on_conflict: :nothing,
conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz]
)
enqueue_jobs(tuples, mission) enqueue_jobs(tuples, mission)
:ok :ok
end end
defp enqueue_jobs(tuples, mission) do defp enqueue_jobs(tuples, mission) do
for {rover_id, station_id, band_mhz} <- tuples do changesets =
%{ Enum.map(tuples, fn {rover_id, station_id, band_mhz} ->
"mission_id" => mission.id, RoverPathProfileWorker.new(%{
"rover_location_id" => rover_id, "mission_id" => mission.id,
"station_id" => station_id, "rover_location_id" => rover_id,
"band_mhz" => band_mhz "station_id" => station_id,
} "band_mhz" => band_mhz
|> RoverPathProfileWorker.new() })
|> Oban.insert() end)
end
Oban.insert_all(changesets)
:ok :ok
end end