perf: fix remaining medium/low severity inefficiencies

- 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
This commit is contained in:
Graham McIntire 2026-06-01 15:39:16 -05:00
parent 9cce257db7
commit 15f4175cce
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 35 additions and 47 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,11 +78,15 @@ 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
Cache.fetch_or_store(@monthly_bars_cache_key, @monthly_bars_ttl_ms, fn ->
by_month =
Contact
|> where([c], not is_nil(c.qso_timestamp))
@ -106,6 +111,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
height: height
}
end)
end)
end
defp bar_height(_count, 0), do: 0