From 26be066f77c19513f97d8a0819e9b1f479a1b58f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 1 Jun 2026 15:29:29 -0500 Subject: [PATCH] perf: fix critical performance issues - NexradCache: replace :ets.tab2list() with foldl to avoid ~1.3 GB heap allocation on full cache (OOM risk) - GridCache: replace :ets.select full-table copies with foldl in ets_latest_valid_time and ets_prune_keep_latest - Weather.backfill_hrrr_batch: batch N individual UPDATEs into single VALUES-based SQL statement eliminating ~500 round-trips per batch - Add 9 missing database indexes across 6 tables for hot-path queries (contacts, contact_edits, hrrr_profiles, hrrr_native_profiles, grid_tasks, rover_mission_paths, fixed_stations, beacons) --- lib/microwaveprop/weather.ex | 50 ++++++++++++++----- lib/microwaveprop/weather/grid_cache.ex | 17 ++++--- lib/microwaveprop/weather/nexrad_cache.ex | 26 ++++++---- ...20260601202826_add_performance_indexes.exs | 43 ++++++++++++++++ 4 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 priv/repo/migrations/20260601202826_add_performance_indexes.exs diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index c9ffcd61..9371419e 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -537,19 +537,45 @@ defmodule Microwaveprop.Weather do end end) - Enum.each(updates, fn u -> - HrrrProfile - |> where([h], h.id == ^u.id) - |> Repo.update_all( - set: [ - surface_refractivity: u.surface_refractivity, - min_refractivity_gradient: u.min_refractivity_gradient, - ducting_detected: u.ducting_detected, - duct_characteristics: u.duct_characteristics, - updated_at: DateTime.truncate(DateTime.utc_now(), :second) - ] + now = DateTime.truncate(DateTime.utc_now(), :second) + + if updates != [] do + {values_sql, params} = + updates + |> Enum.with_index() + |> Enum.reduce({"", []}, fn u, {sql, params} -> + base = Enum.count(params) + frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})" + sep = if sql == "", do: "", else: ", " + + params = + params ++ + [ + Ecto.UUID.dump!(u.id), + u.surface_refractivity, + u.min_refractivity_gradient, + u.ducting_detected, + u.duct_characteristics || [], + now + ] + + {sql <> sep <> frag, params} + end) + + Repo.query!( + "UPDATE hrrr_profiles AS h " <> + "SET surface_refractivity = v.surface_refractivity, " <> + "min_refractivity_gradient = v.min_refractivity_gradient, " <> + "ducting_detected = v.ducting_detected, " <> + "duct_characteristics = v.duct_characteristics, " <> + "updated_at = v.updated_at " <> + "FROM (VALUES #{values_sql}) " <> + "AS v(id, surface_refractivity, min_refractivity_gradient, " <> + "ducting_detected, duct_characteristics, updated_at) " <> + "WHERE h.id = v.id", + params ) - end) + end length(updates) end diff --git a/lib/microwaveprop/weather/grid_cache.ex b/lib/microwaveprop/weather/grid_cache.ex index c3776e7d..37b26601 100644 --- a/lib/microwaveprop/weather/grid_cache.ex +++ b/lib/microwaveprop/weather/grid_cache.ex @@ -291,20 +291,21 @@ defmodule Microwaveprop.Weather.GridCache do end defp ets_latest_valid_time do - match_spec = [{{:"$1", :_}, [], [:"$1"]}] - - case :ets.select(@table, match_spec) do - [] -> nil - times -> Enum.max(times, DateTime) - end + :ets.foldl(fn + {vt, _}, nil -> vt + {vt, _}, latest -> if DateTime.compare(vt, latest) == :gt, do: vt, else: latest + end, nil, @table) end defp ets_prune_keep_latest(keep) do - case :ets.select(@table, [{{:"$1", :_}, [], [:"$1"]}]) do + times = + :ets.foldl(fn {vt, _}, acc -> [vt | acc] end, [], @table) + + case times do [] -> 0 - times -> + _ -> sorted = Enum.sort(times, {:desc, DateTime}) to_drop = Enum.drop(sorted, keep) Enum.each(to_drop, &:ets.delete(@table, &1)) diff --git a/lib/microwaveprop/weather/nexrad_cache.ex b/lib/microwaveprop/weather/nexrad_cache.ex index 79917457..b6350076 100644 --- a/lib/microwaveprop/weather/nexrad_cache.ex +++ b/lib/microwaveprop/weather/nexrad_cache.ex @@ -44,21 +44,25 @@ defmodule Microwaveprop.Weather.NexradCache do end defp enforce_size_cap do - size = :ets.info(@table, :size) + if :ets.info(@table, :size) > @max_entries do + case oldest_key() do + nil -> :ok + ts -> :ets.delete(@table, ts) + end - if size > @max_entries do - # Drop the oldest-by-key entries until we're back under the cap. - # O(n log n) but n is ~20, so this runs in microseconds. - excess = size - @max_entries - - @table - |> :ets.tab2list() - |> Enum.sort_by(fn {ts, _, _} -> DateTime.to_unix(ts) end) - |> Enum.take(excess) - |> Enum.each(fn {ts, _, _} -> :ets.delete(@table, ts) end) + enforce_size_cap() end end + # Iterate ETS with foldl so frame payloads (~66 MB each) never land on the + # calling process's heap — :ets.tab2list() at capacity would allocate ~1.3 GB. + defp oldest_key do + :ets.foldl(fn + {ts, _, _}, nil -> ts + {ts, _, _}, oldest -> if DateTime.before?(ts, oldest), do: ts, else: oldest + end, nil, @table) + end + @spec prune_older_than(DateTime.t()) :: non_neg_integer() def prune_older_than(cutoff_ts) do match_spec = [{{:"$1", :_, :_}, [{:<, :"$1", {:const, cutoff_ts}}], [true]}] diff --git a/priv/repo/migrations/20260601202826_add_performance_indexes.exs b/priv/repo/migrations/20260601202826_add_performance_indexes.exs new file mode 100644 index 00000000..f93096e2 --- /dev/null +++ b/priv/repo/migrations/20260601202826_add_performance_indexes.exs @@ -0,0 +1,43 @@ +defmodule Microwaveprop.Repo.Migrations.AddPerformanceIndexes do + use Ecto.Migration + + def change do + # contacts.flagged_invalid — admin flagged-contacts page filters + sorts + create index(:contacts, [:flagged_invalid, :inserted_at, :id]) + + # contact_edits.inserted_at — ORDER BY DESC on list and per-contact queries + create index(:contact_edits, [:inserted_at]) + + # contact_edits(contact_id, user_id, status) — pending-edit lookup + create index(:contact_edits, [:contact_id, :user_id, :status]) + + # hrrr_profiles.surface_refractivity IS NULL — backfill query scans for nulls + create index(:hrrr_profiles, [:surface_refractivity], + where: "surface_refractivity IS NULL", + name: "hrrr_profiles_surface_refractivity_null_idx" + ) + + # hrrr_native_profiles.bulk_richardson IS NULL — derive task scans for nulls + create index(:hrrr_native_profiles, [:bulk_richardson], + where: "bulk_richardson IS NULL AND level_count > 2", + name: "hrrr_native_profiles_bulk_richardson_null_idx" + ) + + # grid_tasks.claimed_at for status='running' — reclaim-stale query + create index(:grid_tasks, [:claimed_at], + where: "status = 'running'", + name: "grid_tasks_running_claimed_at_idx" + ) + + # rover_mission_paths composite — worker lookup on all 4 columns + create index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id, :band_mhz], + name: "rover_mission_paths_lookup_idx" + ) + + # fixed_stations(user_id, position, inserted_at) — WHERE + ORDER BY + create index(:fixed_stations, [:user_id, :position, :inserted_at]) + + # beacons(user_id, inserted_at) — WHERE + ORDER BY + create index(:beacons, [:user_id, :inserted_at]) + end +end