From 15f4175cce2f547a174724762b009bbf7c26c155 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 1 Jun 2026 15:39:16 -0500 Subject: [PATCH] perf: fix remaining medium/low severity inefficiencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Radio.group_reciprocals: replace O(n²) nested Enum.filter with O(n) Enum.group_by using a hash key (sorted station pair + band + hour) - contact_live/index.ex: cache monthly bars Repo.all(Contact) query with 60s TTL - ScoreCache.valid_times: replace :ets.select with :ets.foldl to avoid intermediate list allocation - Accounts.list_users: add limit(100) to unbounded query - Accounts.backfill_missing_home_qth: batch individual Oban.insert calls into single Oban.insert_all --- lib/microwaveprop/accounts.ex | 9 ++--- lib/microwaveprop/propagation/score_cache.ex | 11 +++-- lib/microwaveprop/radio.ex | 40 +++++-------------- .../live/contact_live/index.ex | 22 ++++++---- 4 files changed, 35 insertions(+), 47 deletions(-) diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index 8fbc5342..01d6d9b5 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -90,10 +90,10 @@ defmodule Microwaveprop.Accounts do ## Admin user management - @doc "Returns all users ordered by callsign." + @doc "Returns users ordered by callsign, limited to 100." @spec list_users() :: [User.t()] def list_users do - Repo.all(from u in User, order_by: [asc: u.callsign]) + Repo.all(from u in User, order_by: [asc: u.callsign], limit: 100) end @doc "Updates admin-managed user fields (callsign, name, email, is_admin)." @@ -168,9 +168,8 @@ defmodule Microwaveprop.Accounts do |> where([u], is_nil(u.home_grid)) |> select([u], u.id) |> Repo.all() - |> Enum.each(fn id -> - _ = %{user_id: id} |> worker.new() |> Oban.insert() - end) + |> Enum.map(fn id -> %{user_id: id} |> worker.new() end) + |> then(&if &1 != [], do: Oban.insert_all(&1)) end :ok diff --git a/lib/microwaveprop/propagation/score_cache.ex b/lib/microwaveprop/propagation/score_cache.ex index b8e6da66..cfdd5fdc 100644 --- a/lib/microwaveprop/propagation/score_cache.ex +++ b/lib/microwaveprop/propagation/score_cache.ex @@ -149,10 +149,15 @@ defmodule Microwaveprop.Propagation.ScoreCache do @doc "Returns the sorted list of valid_times cached for `band_mhz`." @spec valid_times(non_neg_integer()) :: [DateTime.t()] def valid_times(band_mhz) do - match_spec = [{{{band_mhz, :"$1"}, :_}, [], [:"$1"]}] - @table - |> :ets.select(match_spec) + |> :ets.foldl( + fn + {{^band_mhz, vt}, _}, acc -> [vt | acc] + _, acc -> acc + end, + [], + @table + ) |> Enum.sort({:asc, DateTime}) end diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index b3c9c5ef..a382cec9 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -230,42 +230,20 @@ defmodule Microwaveprop.Radio do """ @spec group_reciprocals([Contact.t()]) :: [{Contact.t(), [Contact.t()]}] def group_reciprocals(contacts) do - {groups, _seen_ids} = - Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} -> - group_contact(contact, contacts, groups, seen) - end) - - Enum.reverse(groups) + contacts + |> Enum.group_by(&reciprocal_key/1) + |> Enum.map(fn {_key, [primary | rest]} -> {primary, rest} end) end - defp group_contact(contact, contacts, groups, seen) do - if MapSet.member?(seen, contact.id) do - {groups, seen} - else - reciprocals = - Enum.filter(contacts, fn other -> - other.id != contact.id and - not MapSet.member?(seen, other.id) and - other.band == contact.band and - reciprocal_pair?(contact, other) - end) - - new_seen = Enum.reduce(reciprocals, MapSet.put(seen, contact.id), &MapSet.put(&2, &1.id)) - {[{contact, reciprocals} | groups], new_seen} - end + defp reciprocal_key(contact) do + stations = Enum.sort([contact.station1, contact.station2]) + {stations, contact.band, reciprocal_hour_key(contact.qso_timestamp)} end - defp reciprocal_pair?(a, b) do - same_stations = - (a.station1 == b.station2 and a.station2 == b.station1) or - (a.station1 == b.station1 and a.station2 == b.station2) + defp reciprocal_hour_key(nil), do: nil - same_stations and same_hour?(a.qso_timestamp, b.qso_timestamp) - end - - defp same_hour?(t1, t2) do - t1 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour]) == - t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour]) + defp reciprocal_hour_key(%_{} = ts) do + Map.take(ts, [:year, :month, :day, :hour]) end # Only cache the unscoped, unsearched total — scope-dependent counts diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index 8ecf46c8..0fa79b3e 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do alias Microwaveprop.Accounts.Scope alias Microwaveprop.Accounts.User + alias Microwaveprop.Cache alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo @@ -77,18 +78,22 @@ defmodule MicrowavepropWeb.ContactLive.Index do |> assign(:data_provider, {__MODULE__, :visible_query_provider, [scope_token(scope)]})} end + @monthly_bars_cache_key {__MODULE__, :monthly_bars} + @monthly_bars_ttl_ms 60_000 + # Counts every contact in the corpus regardless of viewer scope — # the chart is a public-facing seasonal summary, not a personal # view, so private contacts roll into the per-month totals like - # any other. + # any other. Cached for 60s since data only changes on new inserts. defp monthly_bars do - by_month = - Contact - |> where([c], not is_nil(c.qso_timestamp)) - |> group_by([c], fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp)) - |> select([c], {fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp), count(c.id)}) - |> Repo.all() - |> Map.new() + Cache.fetch_or_store(@monthly_bars_cache_key, @monthly_bars_ttl_ms, fn -> + by_month = + Contact + |> where([c], not is_nil(c.qso_timestamp)) + |> group_by([c], fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp)) + |> select([c], {fragment("EXTRACT(MONTH FROM ?)::int", c.qso_timestamp), count(c.id)}) + |> Repo.all() + |> Map.new() counts = Enum.map(1..12, fn m -> {m, Map.get(by_month, m, 0)} end) max_count = counts |> Enum.map(&elem(&1, 1)) |> Enum.max() @@ -106,6 +111,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do height: height } end) + end) end defp bar_height(_count, 0), do: 0