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