perf: fix high-severity LiveView blocking queries

- rover_planning_live/show.ex: use pre-loaded @rover_sites assign
  instead of Repo.all(Location) on every keystroke
- rover_locations_live/map.ex: cache locations query with 30s TTL
  (was loading + JSON-encoding all good locations in every mount)
- status_live.ex: consolidate 5 separate stat fetches into single
  cached blob, so PubSub-triggered refreshes hit cache instead of
  running 10+ DB queries each time
This commit is contained in:
Graham McIntire 2026-06-01 15:31:28 -05:00
parent 26be066f77
commit 473c2ab0ec
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 47 additions and 34 deletions

View file

@ -7,17 +7,17 @@ defmodule MicrowavepropWeb.RoverLocationsLive.Map do
import Ecto.Query import Ecto.Query
alias Microwaveprop.Cache
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location alias Microwaveprop.Rover.Location
@cache_key {__MODULE__, :points}
@cache_ttl_ms 30_000
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
points = points = cached_points()
Location
|> where([l], l.status == :good)
|> Repo.all()
|> Enum.map(&point_payload/1)
{:ok, {:ok,
assign(socket, assign(socket,
@ -27,6 +27,15 @@ defmodule MicrowavepropWeb.RoverLocationsLive.Map do
)} )}
end end
defp cached_points do
Cache.fetch_or_store(@cache_key, @cache_ttl_ms, fn ->
Location
|> where([l], l.status == :good)
|> Repo.all()
|> Enum.map(&point_payload/1)
end)
end
defp point_payload(%Location{} = loc) do defp point_payload(%Location{} = loc) do
%{ %{
id: loc.id, id: loc.id,

View file

@ -51,7 +51,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
{:noreply, {:noreply,
assign(socket, assign(socket,
rover_site_input: input, rover_site_input: input,
rover_site_input_matches: find_grid_matches(input) rover_site_input_matches: find_grid_matches(input, socket.assigns.rover_sites)
)} )}
end end
@ -112,20 +112,14 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp authenticated(_), do: {:error, :unauthenticated} defp authenticated(_), do: {:error, :unauthenticated}
# Resolve the user-typed input and surface any existing rover-locations defp find_grid_matches(input, locations) when is_binary(input) do
# that share its 6-char Maidenhead grid (sub-grid resolution ≈ 5 km).
# Cheap enough for ~hundreds of locations: we map them in memory rather
# than maintaining a stored grid column.
defp find_grid_matches(input) when is_binary(input) do
trimmed = String.trim(input) trimmed = String.trim(input)
case trimmed != "" && LocationResolver.resolve(trimmed) do case trimmed != "" && LocationResolver.resolve(trimmed) do
{:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) -> {:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) ->
target = Maidenhead.from_latlon(lat, lon, 6) target = Maidenhead.from_latlon(lat, lon, 6)
Location Enum.filter(locations, fn loc ->
|> Repo.all()
|> Enum.filter(fn loc ->
Maidenhead.from_latlon(loc.lat, loc.lon, 6) == target Maidenhead.from_latlon(loc.lat, loc.lon, 6) == target
end) end)
@ -134,7 +128,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
end end
end end
defp find_grid_matches(_), do: [] defp find_grid_matches(_, _), do: []
defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true

View file

@ -10,6 +10,9 @@ defmodule MicrowavepropWeb.StatusLive do
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Weather.NarrClient alias Microwaveprop.Weather.NarrClient
@all_stats_cache_key {__MODULE__, :all_stats}
@stats_cache_ttl_ms 2_000
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
_ = _ =
@ -19,24 +22,20 @@ defmodule MicrowavepropWeb.StatusLive do
# Rust prop-grid-rs emits NOTIFY propagation_ready on completion — # Rust prop-grid-rs emits NOTIFY propagation_ready on completion —
# PropagationNotifyListener fans it out as `propagation:updated` so # PropagationNotifyListener fans it out as `propagation:updated` so
# the grid_tasks panel transitions from "running" → "done" without # the grid_tasks panel transitions from "running" → "done" without
# a manual refresh. # a single refresh.
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end end
stats = fetch_stats() all = fetch_all_stats()
unprocessed = count_unprocessed()
db_stats = fetch_db_stats()
grid_tasks = fetch_grid_tasks_stats()
hrrr_point_tasks = fetch_hrrr_point_tasks_stats()
{:ok, {:ok,
assign(socket, assign(socket,
page_title: "Status", page_title: "Status",
stats: stats, stats: all.stats,
unprocessed: unprocessed, unprocessed: all.unprocessed,
db_stats: db_stats, db_stats: all.db_stats,
grid_tasks: grid_tasks, grid_tasks: all.grid_tasks,
hrrr_point_tasks: hrrr_point_tasks, hrrr_point_tasks: all.hrrr_point_tasks,
refresh_timer: nil refresh_timer: nil
)} )}
end end
@ -55,25 +54,36 @@ defmodule MicrowavepropWeb.StatusLive do
end end
def handle_info(:refresh_stats, socket) do def handle_info(:refresh_stats, socket) do
all = fetch_all_stats()
{:noreply, {:noreply,
assign(socket, assign(socket,
stats: fetch_stats(), stats: all.stats,
unprocessed: count_unprocessed(), unprocessed: all.unprocessed,
db_stats: fetch_db_stats(), db_stats: all.db_stats,
grid_tasks: fetch_grid_tasks_stats(), grid_tasks: all.grid_tasks,
hrrr_point_tasks: fetch_hrrr_point_tasks_stats(), hrrr_point_tasks: all.hrrr_point_tasks,
refresh_timer: nil refresh_timer: nil
)} )}
end end
defp fetch_all_stats do
Cache.fetch_or_store(@all_stats_cache_key, @stats_cache_ttl_ms, fn ->
%{
stats: fetch_stats(),
unprocessed: count_unprocessed(),
db_stats: fetch_db_stats(),
grid_tasks: fetch_grid_tasks_stats(),
hrrr_point_tasks: fetch_hrrr_point_tasks_stats()
}
end)
end
defp schedule_refresh(%{assigns: %{refresh_timer: ref}} = socket) when is_reference(ref) do defp schedule_refresh(%{assigns: %{refresh_timer: ref}} = socket) when is_reference(ref) do
# Already scheduled, skip
socket socket
end end
defp schedule_refresh(socket) do defp schedule_refresh(socket) do
# 2s debounce — bulk enrichment runs produce hundreds of status_changed
# events per second, and each stats refresh fires 8+ count queries.
ref = Process.send_after(self(), :refresh_stats, 2_000) ref = Process.send_after(self(), :refresh_stats, 2_000)
assign(socket, refresh_timer: ref) assign(socket, refresh_timer: ref)
end end