defmodule Microwaveprop.Propagation.ScoreCache do @moduledoc """ Node-local ETS cache of propagation scores keyed by `{band_mhz, valid_time}`. Populated lazily by `Microwaveprop.Propagation.scores_at/3` on the first request for a given `{band_mhz, valid_time}`; subsequent requests hit ETS until the entry is evicted. The cache is bounded to `max_entries/0` total entries via LRU-by-`valid_time` eviction; on overflow the oldest `valid_time` is dropped first so the freshly-arrived hour stays warm. `broadcast_put/3` and the matching `"propagation:cache"` PubSub topic are retained for the rare cluster-wide pre-warm path; the hot pipeline (Rust Phase 2 cutover) does not call them — `NotifyListener` only emits the lighter-weight `"propagation:updated"` PubSub so LiveView clients refresh while the actual scores load on demand from `/data/scores/.../*.prop`. ## Storage layout Each cache entry is bucketed into 5°×5° spatial chunks matching the layout used by `Microwaveprop.Weather.GridCache`. The ETS value for `{band_mhz, valid_time}` is `%{{lat_band, lon_band} => %{{lat, lon} => score}}`. That way `fetch_bounds/3` only walks the chunks that intersect the requested viewport instead of the full 92k-cell CONUS map; `fetch_point/4` reads exactly one chunk. """ use GenServer alias Phoenix.PubSub @table :propagation_score_cache @topic "propagation:cache" @pubsub Microwaveprop.PubSub @chunk_step 5 # Hard cap on cached `{band_mhz, valid_time}` entries. Each value is a # 92k-cell map (now bucketed into 5°×5° chunks) on the order of ~2 MiB; # without a cap the eager warm path materialises 23 bands × 19 forecast # hours = 437 entries per pod (~850 MiB) and OOMs the BEAM. The cap # keeps the LiveView hot path warm (one band × the hour or two the # user is actively scrubbing) and lets the rest fall back to direct # `.prop` reads via `read_from_disk_and_cache/3`, which is sub-100 ms # per file. @max_entries 32 @type score :: %{lat: float(), lon: float(), score: non_neg_integer()} @type bounds :: %{optional(String.t()) => float()} # ---------- Public API ---------- @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @spec fetch(non_neg_integer(), DateTime.t()) :: {:ok, [score()]} | :miss def fetch(band_mhz, valid_time) do case :ets.lookup(@table, {band_mhz, valid_time}) do [{_, chunked}] -> {:ok, chunks_to_list(chunked)} [] -> :miss end end @spec fetch_bounds(non_neg_integer(), DateTime.t(), bounds() | nil) :: {:ok, [score()]} | :miss def fetch_bounds(band_mhz, valid_time, bounds) do case :ets.lookup(@table, {band_mhz, valid_time}) do [{_, chunked}] -> {:ok, chunks_filtered_to_list(chunked, bounds)} [] -> :miss end end @doc """ Look up the score for a single `{lat, lon}` point at `{band_mhz, valid_time}`. Returns `{:ok, score}` on hit or `:miss` on cache miss. The coordinates are used as-is (no snap-to-grid) — callers that need the nearest grid cell should snap first. """ @spec fetch_point(non_neg_integer(), DateTime.t(), float(), float()) :: {:ok, non_neg_integer()} | :miss def fetch_point(band_mhz, valid_time, lat, lon) do case :ets.lookup(@table, {band_mhz, valid_time}) do [{_, chunked}] -> chunk_key = chunk_key_for(lat, lon) case chunked |> Map.get(chunk_key, %{}) |> Map.get({lat, lon}) do nil -> :miss score -> {:ok, score} end [] -> :miss end end @doc "Maximum number of `{band_mhz, valid_time}` entries kept in ETS." @spec max_entries() :: pos_integer() def max_entries, do: @max_entries @spec put(non_neg_integer(), DateTime.t(), [score()]) :: :ok def put(band_mhz, valid_time, scores) do chunked = list_to_chunks(scores) :ets.insert(@table, {{band_mhz, valid_time}, chunked}) enforce_capacity() :ok end # When the table is over the cap, evict whichever entry has the oldest # `valid_time`. Picking the oldest matches the LiveView access pattern: # the timeline scrubs forward, the hourly chain replaces the trailing # edge, and the freshest hours are by far the most-requested. Loops # until the table is back at or below the cap so that any historical # over-shoot self-heals after this commit deploys. defp enforce_capacity do if :ets.info(@table, :size) > @max_entries do case oldest_key() do nil -> :ok key -> :ets.delete(@table, key) end enforce_capacity() end end defp oldest_key do fun = fn {{band, vt}, _}, nil -> {band, vt} {{band, vt}, _}, {_, oldest} = acc -> if DateTime.before?(vt, oldest), do: {band, vt}, else: acc end :ets.foldl(fun, nil, @table) end @doc """ Insert locally AND broadcast to peer nodes via PubSub. Call this from the single pod that computed the scores; every cluster member (including the caller) will receive the PubSub message and populate its local ETS. Use `put/3` directly if you already have a cluster-wide fan-out. """ @spec broadcast_put(non_neg_integer(), DateTime.t(), [score()]) :: :ok def broadcast_put(band_mhz, valid_time, scores) do _ = PubSub.broadcast(@pubsub, @topic, {:cache_refresh, band_mhz, valid_time, scores}) :ok end @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) |> Enum.sort({:asc, DateTime}) end @spec prune_older_than(DateTime.t()) :: non_neg_integer() def prune_older_than(cutoff) do match_spec = [ {{{:_, :"$1"}, :_}, [{:<, :"$1", {:const, cutoff}}], [true]} ] :ets.select_delete(@table, match_spec) end @doc """ Delete every entry whose `valid_time` falls outside the inclusive window `[past_cutoff, future_cutoff]`. Used to bound the cache to the active forecast horizon (past ~1 h + HRRR's 18 h forward) so long-horizon GEFS valid_times never accumulate in ETS. """ @spec prune_outside_window(DateTime.t(), DateTime.t()) :: non_neg_integer() def prune_outside_window(past_cutoff, future_cutoff) do match_spec = [ {{{:_, :"$1"}, :_}, [ {:orelse, {:<, :"$1", {:const, past_cutoff}}, {:>, :"$1", {:const, future_cutoff}}} ], [true]} ] :ets.select_delete(@table, match_spec) end @spec clear() :: :ok def clear do :ets.delete_all_objects(@table) :ok end @doc "Flush the GenServer mailbox so any in-flight cache_refresh messages are applied." @spec sync() :: :ok def sync do GenServer.call(__MODULE__, :sync) end # ---------- GenServer callbacks ---------- @impl true def init(_opts) do _ = :ets.new(@table, [:set, :named_table, :public, :compressed, read_concurrency: true]) :ok = PubSub.subscribe(@pubsub, @topic) {:ok, %{}} end @impl true def handle_call(:sync, _from, state), do: {:reply, :ok, state} @impl true def handle_info({:cache_refresh, band_mhz, valid_time, scores}, state) do put(band_mhz, valid_time, scores) {:noreply, state} end def handle_info(_msg, state), do: {:noreply, state} # ---------- Internal ---------- defp list_to_chunks(scores) do Enum.reduce(scores, %{}, fn %{lat: lat, lon: lon, score: score}, acc -> key = chunk_key_for(lat, lon) chunk = Map.get(acc, key, %{}) Map.put(acc, key, Map.put(chunk, {lat, lon}, score)) end) end defp chunks_to_list(chunked) do Enum.flat_map(chunked, fn {_chunk_key, cells} -> Enum.map(cells, fn {{lat, lon}, score} -> %{lat: lat, lon: lon, score: score} end) end) end defp chunks_filtered_to_list(chunked, nil), do: chunks_to_list(chunked) defp chunks_filtered_to_list(chunked, %{"south" => s, "north" => n, "west" => w, "east" => e} = bounds) do chunked |> Enum.filter(fn {chunk_key, _} -> chunk_intersects_bounds?(chunk_key, bounds) end) |> Enum.flat_map(fn {_, cells} -> for {{lat, lon}, score} <- cells, lat >= s, lat <= n, lon >= w, lon <= e, do: %{lat: lat, lon: lon, score: score} end) end defp chunk_key_for(lat, lon) do {chunk_band(lat * 1.0), chunk_band(lon * 1.0)} end defp chunk_band(value) when is_float(value) do (value / @chunk_step) |> Float.floor() |> trunc() end defp chunk_intersects_bounds?({lat_band, lon_band}, %{"south" => s, "north" => n, "west" => w, "east" => e}) do chunk_south = lat_band * @chunk_step chunk_north = chunk_south + @chunk_step chunk_west = lon_band * @chunk_step chunk_east = chunk_west + @chunk_step chunk_north >= s and chunk_south <= n and chunk_east >= w and chunk_west <= e end end