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

View file

@ -51,7 +51,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
{:noreply,
assign(socket,
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
@ -112,20 +112,14 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp authenticated(_), do: {:error, :unauthenticated}
# Resolve the user-typed input and surface any existing rover-locations
# 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
defp find_grid_matches(input, locations) when is_binary(input) do
trimmed = String.trim(input)
case trimmed != "" && LocationResolver.resolve(trimmed) do
{:ok, %{lat: lat, lon: lon}} when is_number(lat) and is_number(lon) ->
target = Maidenhead.from_latlon(lat, lon, 6)
Location
|> Repo.all()
|> Enum.filter(fn loc ->
Enum.filter(locations, fn loc ->
Maidenhead.from_latlon(loc.lat, loc.lon, 6) == target
end)
@ -134,7 +128,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
end
end
defp find_grid_matches(_), do: []
defp find_grid_matches(_, _), do: []
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.Weather.NarrClient
@all_stats_cache_key {__MODULE__, :all_stats}
@stats_cache_ttl_ms 2_000
@impl true
def mount(_params, _session, socket) do
_ =
@ -19,24 +22,20 @@ defmodule MicrowavepropWeb.StatusLive do
# Rust prop-grid-rs emits NOTIFY propagation_ready on completion —
# PropagationNotifyListener fans it out as `propagation:updated` so
# the grid_tasks panel transitions from "running" → "done" without
# a manual refresh.
# a single refresh.
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
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()
all = fetch_all_stats()
{:ok,
assign(socket,
page_title: "Status",
stats: stats,
unprocessed: unprocessed,
db_stats: db_stats,
grid_tasks: grid_tasks,
hrrr_point_tasks: hrrr_point_tasks,
stats: all.stats,
unprocessed: all.unprocessed,
db_stats: all.db_stats,
grid_tasks: all.grid_tasks,
hrrr_point_tasks: all.hrrr_point_tasks,
refresh_timer: nil
)}
end
@ -55,25 +54,36 @@ defmodule MicrowavepropWeb.StatusLive do
end
def handle_info(:refresh_stats, socket) do
all = fetch_all_stats()
{:noreply,
assign(socket,
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(),
stats: all.stats,
unprocessed: all.unprocessed,
db_stats: all.db_stats,
grid_tasks: all.grid_tasks,
hrrr_point_tasks: all.hrrr_point_tasks,
refresh_timer: nil
)}
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
# Already scheduled, skip
socket
end
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)
assign(socket, refresh_timer: ref)
end