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
alias Ecto.Multi
alias Microwaveprop.Accounts.User
alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location
@ -60,7 +59,13 @@ defmodule Microwaveprop.RoverPlanning do
case Repo.insert(changeset) do
{:ok, mission} ->
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}
{: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(tuples, mission) when is_list(tuples) do
{:ok, _} =
tuples
|> Enum.reduce(Multi.new(), fn {rover_id, station_id, band_mhz}, multi ->
attrs = %{
# Batch the path-row inserts and the Oban-job inserts into a
# single SQL statement each. The previous Multi-of-N-inserts +
# N x Oban.insert call took ~1.5s per pair × 900 pairs in the
# 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,
rover_location_id: rover_id,
station_id: station_id,
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)
|> 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)
:ok
end
defp enqueue_jobs(tuples, mission) do
for {rover_id, station_id, band_mhz} <- tuples do
%{
"mission_id" => mission.id,
"rover_location_id" => rover_id,
"station_id" => station_id,
"band_mhz" => band_mhz
}
|> RoverPathProfileWorker.new()
|> Oban.insert()
end
changesets =
Enum.map(tuples, fn {rover_id, station_id, band_mhz} ->
RoverPathProfileWorker.new(%{
"mission_id" => mission.id,
"rover_location_id" => rover_id,
"station_id" => station_id,
"band_mhz" => band_mhz
})
end)
Oban.insert_all(changesets)
:ok
end