From 3060eddfb2aa8fea1304a5790c9125beb6413c94 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 3 May 2026 15:05:12 -0500 Subject: [PATCH] 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. --- lib/microwaveprop/rover_planning.ex | 62 +++++++++++++++++------------ 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/lib/microwaveprop/rover_planning.ex b/lib/microwaveprop/rover_planning.ex index 4f46c7d0..75433546 100644 --- a/lib/microwaveprop/rover_planning.ex +++ b/lib/microwaveprop/rover_planning.ex @@ -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