Stream contact detail sections in as each enrichment loads
The contact detail page used to gather every enrichment source in one parallel_hydrate/2 bundle and await all five tasks before updating any assigns, so users watched the whole right-hand column flash in at the speed of the slowest query (usually hrrr_profiles_for_path against a 42M-row table). Each source now runs in its own start_async task: :weather, :solar, :hrrr_path, :terrain, :iemre. Matching handle_async/3 clauses assign the slot and call refresh_computed/1, which rebuilds elevation_profile + propagation_analysis + data_sources from whatever's currently in assigns. Sections appear independently as their data arrives, and the existing PubSub handlers (:hrrr_ready, :weather_ready, :terrain_ready, :solar_ready) still pick up later backfill completions the same way they did before.
This commit is contained in:
parent
480b6decdf
commit
7ed429364a
1 changed files with 152 additions and 98 deletions
|
|
@ -30,48 +30,61 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
|
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
|
||||||
can_enqueue = internal_network?(session)
|
can_enqueue = internal_network?(session)
|
||||||
|
|
||||||
if connected?(socket) do
|
socket =
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
|
assign(socket,
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:hrrr")
|
page_title: "#{contact.station1} / #{contact.station2}",
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:weather")
|
contact: contact,
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:solar")
|
can_enqueue: can_enqueue,
|
||||||
|
surface_observations: [],
|
||||||
|
soundings: [],
|
||||||
|
solar: nil,
|
||||||
|
hrrr: nil,
|
||||||
|
hrrr_path: [],
|
||||||
|
iemre: nil,
|
||||||
|
terrain: nil,
|
||||||
|
elevation_profile: nil,
|
||||||
|
propagation_analysis: nil,
|
||||||
|
data_sources: nil,
|
||||||
|
terrain_expanded: false,
|
||||||
|
hrrr_profile_expanded: false,
|
||||||
|
obs_sort_by: "station_name",
|
||||||
|
obs_sort_order: "asc",
|
||||||
|
sounding_sort_by: "station_name",
|
||||||
|
sounding_sort_order: "asc",
|
||||||
|
queue_counts: fetch_queue_counts(),
|
||||||
|
expanded_soundings: MapSet.new(),
|
||||||
|
editing: false,
|
||||||
|
edit_form: nil,
|
||||||
|
pending_edit: load_pending_edit(contact, socket),
|
||||||
|
band_options: @band_options,
|
||||||
|
mode_options: @mode_options
|
||||||
|
)
|
||||||
|
|
||||||
# Defer the expensive enrichment loads (HRRR profiles, terrain profile,
|
socket =
|
||||||
# IEMRE, elevation profile, propagation analysis — each touching large
|
if connected?(socket) do
|
||||||
# tables or doing ITU-R math) until after the initial shell has been
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:#{contact.id}")
|
||||||
# sent. Users see the contact basics in ~20ms instead of waiting the
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:hrrr")
|
||||||
# full ~500ms-2s mount.
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:weather")
|
||||||
send(self(), {:hydrate, can_enqueue})
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "contact_enrichment:solar")
|
||||||
end
|
|
||||||
|
|
||||||
{:ok,
|
kickoff_hydration(socket, contact)
|
||||||
assign(socket,
|
else
|
||||||
page_title: "#{contact.station1} / #{contact.station2}",
|
socket
|
||||||
contact: contact,
|
end
|
||||||
surface_observations: [],
|
|
||||||
soundings: [],
|
{:ok, socket}
|
||||||
solar: nil,
|
end
|
||||||
hrrr: nil,
|
|
||||||
iemre: nil,
|
# Each enrichment source loads in its own task so users see sections appear
|
||||||
terrain: nil,
|
# independently instead of waiting for the slowest one. handle_async/3
|
||||||
elevation_profile: nil,
|
# clauses below update the matching slot and re-run the derived analysis.
|
||||||
propagation_analysis: nil,
|
defp kickoff_hydration(socket, contact) do
|
||||||
data_sources: nil,
|
socket
|
||||||
hydrated: false,
|
|> start_async(:weather, fn -> load_weather(contact) end)
|
||||||
terrain_expanded: false,
|
|> start_async(:solar, fn -> load_solar(contact) end)
|
||||||
hrrr_profile_expanded: false,
|
|> start_async(:hrrr_path, fn -> Weather.hrrr_profiles_for_path(contact) end)
|
||||||
obs_sort_by: "station_name",
|
|> start_async(:terrain, fn -> Terrain.get_terrain_profile(contact.id) end)
|
||||||
obs_sort_order: "asc",
|
|> start_async(:iemre, fn -> load_iemre(contact) end)
|
||||||
sounding_sort_by: "station_name",
|
|
||||||
sounding_sort_order: "asc",
|
|
||||||
queue_counts: fetch_queue_counts(),
|
|
||||||
expanded_soundings: MapSet.new(),
|
|
||||||
editing: false,
|
|
||||||
edit_form: nil,
|
|
||||||
pending_edit: load_pending_edit(contact, socket),
|
|
||||||
band_options: @band_options,
|
|
||||||
mode_options: @mode_options
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_pending_edit(contact, socket) do
|
defp load_pending_edit(contact, socket) do
|
||||||
|
|
@ -226,48 +239,76 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:hydrate, can_enqueue}, socket) do
|
def handle_async(:weather, {:ok, weather}, socket) do
|
||||||
contact = socket.assigns.contact
|
contact = socket.assigns.contact
|
||||||
|
|
||||||
# Five independent DB loads run in parallel — total latency drops from
|
contact =
|
||||||
# sum(each) to max(each). The heaviest is `hrrr_profiles_for_path`
|
if socket.assigns.can_enqueue, do: maybe_enqueue_weather(weather, contact), else: contact
|
||||||
# against the 42M-row partitioned table.
|
|
||||||
%{
|
|
||||||
weather: weather,
|
|
||||||
solar: solar,
|
|
||||||
hrrr_path: hrrr_path,
|
|
||||||
terrain: terrain,
|
|
||||||
iemre: iemre
|
|
||||||
} = parallel_hydrate(contact, can_enqueue)
|
|
||||||
|
|
||||||
hrrr = List.first(hrrr_path)
|
socket =
|
||||||
{hrrr, contact} = if can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact}
|
socket
|
||||||
contact = if can_enqueue, do: maybe_enqueue_terrain(terrain, contact), else: contact
|
|> assign(
|
||||||
contact = if can_enqueue, do: maybe_enqueue_weather(weather, contact), else: contact
|
contact: contact,
|
||||||
|
surface_observations: weather.surface_observations,
|
||||||
|
soundings: weather.soundings
|
||||||
|
)
|
||||||
|
|> refresh_computed()
|
||||||
|
|
||||||
elevation_profile = compute_elevation_profile(contact, hrrr_path, weather.soundings)
|
{:noreply, socket}
|
||||||
|
|
||||||
propagation_analysis =
|
|
||||||
build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, weather.soundings)
|
|
||||||
|
|
||||||
data_sources = build_data_sources(hrrr, hrrr_path, terrain, weather, elevation_profile)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
assign(socket,
|
|
||||||
contact: contact,
|
|
||||||
surface_observations: weather.surface_observations,
|
|
||||||
soundings: weather.soundings,
|
|
||||||
solar: solar,
|
|
||||||
hrrr: hrrr,
|
|
||||||
iemre: iemre,
|
|
||||||
terrain: terrain,
|
|
||||||
elevation_profile: elevation_profile,
|
|
||||||
propagation_analysis: propagation_analysis,
|
|
||||||
data_sources: data_sources,
|
|
||||||
hydrated: true
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_async(:solar, {:ok, solar}, socket) do
|
||||||
|
solar =
|
||||||
|
if socket.assigns.can_enqueue and is_nil(solar) do
|
||||||
|
enqueue_missing_solar(socket.assigns.contact)
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
solar
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :solar, solar)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async(:hrrr_path, {:ok, hrrr_path}, socket) do
|
||||||
|
contact = socket.assigns.contact
|
||||||
|
hrrr = List.first(hrrr_path)
|
||||||
|
|
||||||
|
{hrrr, contact} =
|
||||||
|
if socket.assigns.can_enqueue, do: maybe_enqueue_hrrr(hrrr, contact), else: {hrrr, contact}
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(contact: contact, hrrr: hrrr, hrrr_path: hrrr_path)
|
||||||
|
|> refresh_computed()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async(:terrain, {:ok, terrain}, socket) do
|
||||||
|
contact = socket.assigns.contact
|
||||||
|
|
||||||
|
contact =
|
||||||
|
if socket.assigns.can_enqueue, do: maybe_enqueue_terrain(terrain, contact), else: contact
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(contact: contact, terrain: terrain)
|
||||||
|
|> refresh_computed()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async(:iemre, {:ok, iemre}, socket) do
|
||||||
|
{:noreply, assign(socket, :iemre, iemre)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_async(slot, {:exit, reason}, socket)
|
||||||
|
when slot in [:weather, :solar, :hrrr_path, :terrain, :iemre] do
|
||||||
|
Logger.warning("contact hydrate task #{slot} exited: #{inspect(reason)}")
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_info({:terrain_ready, _contact_id}, socket) do
|
def handle_info({:terrain_ready, _contact_id}, socket) do
|
||||||
contact = %{socket.assigns.contact | terrain_status: :complete}
|
contact = %{socket.assigns.contact | terrain_status: :complete}
|
||||||
terrain = Terrain.get_terrain_profile(contact.id)
|
terrain = Terrain.get_terrain_profile(contact.id)
|
||||||
|
|
@ -401,19 +442,39 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
|
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
|
||||||
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
|
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
|
||||||
|
|
||||||
defp parallel_hydrate(contact, can_enqueue) do
|
# Rebuild derived analysis from whatever's currently in assigns. Called from
|
||||||
tasks = %{
|
# each handle_async clause that updates a source slot, so sections that
|
||||||
weather: Task.async(fn -> load_weather(contact) end),
|
# depend on multiple sources (elevation_profile, propagation_analysis,
|
||||||
solar:
|
# data_sources) refresh as each upstream source arrives.
|
||||||
Task.async(fn ->
|
defp refresh_computed(socket) do
|
||||||
if can_enqueue, do: maybe_enqueue_solar(contact), else: load_solar(contact)
|
%{
|
||||||
end),
|
contact: contact,
|
||||||
hrrr_path: Task.async(fn -> Weather.hrrr_profiles_for_path(contact) end),
|
hrrr: hrrr,
|
||||||
terrain: Task.async(fn -> Terrain.get_terrain_profile(contact.id) end),
|
hrrr_path: hrrr_path,
|
||||||
iemre: Task.async(fn -> load_iemre(contact) end)
|
terrain: terrain,
|
||||||
}
|
surface_observations: surface_observations,
|
||||||
|
soundings: soundings
|
||||||
|
} = socket.assigns
|
||||||
|
|
||||||
Map.new(tasks, fn {key, task} -> {key, Task.await(task, 30_000)} end)
|
elevation_profile = compute_elevation_profile(contact, hrrr_path, soundings)
|
||||||
|
|
||||||
|
propagation_analysis =
|
||||||
|
build_propagation_analysis(contact, hrrr_path, terrain, elevation_profile, soundings)
|
||||||
|
|
||||||
|
data_sources =
|
||||||
|
build_data_sources(
|
||||||
|
hrrr,
|
||||||
|
hrrr_path,
|
||||||
|
terrain,
|
||||||
|
%{surface_observations: surface_observations, soundings: soundings},
|
||||||
|
elevation_profile
|
||||||
|
)
|
||||||
|
|
||||||
|
assign(socket,
|
||||||
|
elevation_profile: elevation_profile,
|
||||||
|
propagation_analysis: propagation_analysis,
|
||||||
|
data_sources: data_sources
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_iemre(contact) do
|
defp load_iemre(contact) do
|
||||||
|
|
@ -590,16 +651,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
|> Weather.get_solar_index()
|
|> Weather.get_solar_index()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_enqueue_solar(contact) do
|
defp enqueue_missing_solar(contact) do
|
||||||
solar = load_solar(contact)
|
date = DateTime.to_date(contact.qso_timestamp)
|
||||||
|
Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(date)}))
|
||||||
if solar do
|
|
||||||
solar
|
|
||||||
else
|
|
||||||
date = DateTime.to_date(contact.qso_timestamp)
|
|
||||||
Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(date)}))
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue