prop/lib/microwaveprop/weather/nexrad_cache.ex
Graham McIntire 26be066f77
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)
2026-06-01 15:29:29 -05:00

83 lines
2.5 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Weather.NexradCache do
@moduledoc """
Node-local ETS cache of decoded NEXRAD n0q composite reflectivity frames.
Keyed by a 5-minute rounded timestamp. Stores the raw pixel buffer + image
width so per-point rain-cell extraction can skip the HTTP fetch + PNG decode
(which take 1-5 seconds for a ~5 MB CONUS-wide image).
The cache is populated by `Microwaveprop.Weather.NexradClient.fetch_rain_cells/4`
on its first call per 5-min window, then reused by every concurrent click
until the window rolls over.
"""
use GenServer
@table :nexrad_frame_cache
# Each frame is ~66 MB (12,200 × 5,400 palette-index bytes). 20 frames
# caps at ~1.3 GB — comfortable headroom under a 2 GB pod limit, and
# enough to hit on the common backfill pattern where a worker chews
# through contacts in one timestamp window before moving on. Adjust
# if pod memory limits change or the n0q geometry changes.
@max_entries 20
@type pixels :: binary()
@type width :: pos_integer()
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@spec fetch(DateTime.t()) :: {:ok, pixels(), width()} | :miss
def fetch(rounded_ts) do
case :ets.lookup(@table, rounded_ts) do
[{_, pixels, width}] -> {:ok, pixels, width}
[] -> :miss
end
end
@spec put(DateTime.t(), pixels(), width()) :: :ok
def put(rounded_ts, pixels, width) do
:ets.insert(@table, {rounded_ts, pixels, width})
enforce_size_cap()
:ok
end
defp enforce_size_cap do
if :ets.info(@table, :size) > @max_entries do
case oldest_key() do
nil -> :ok
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]}]
:ets.select_delete(@table, match_spec)
end
@spec clear() :: :ok
def clear do
:ets.delete_all_objects(@table)
:ok
end
@impl true
def init(_opts) do
_ = :ets.new(@table, [:set, :named_table, :public, :compressed, read_concurrency: true])
{:ok, %{}}
end
end