prop/lib/microwaveprop/weather/grid_cache.ex
Graham McIntire b67c0e88c0
perf(weather): cache forecast-hour grids in GridCache to skip ScalarFile decode
Each /weather/tiles request was running the full ScalarFile decode path
(File.read → gunzip → Msgpax.unpack → normalize) for any non-analysis
valid_time, because GridCache deliberately skipped forecast hours to keep
memory low. With 21 simultaneous viewport tiles all hitting the same few
chunk files, each tile took 1.5-3.2s of contended IO + CPU.

On a GridCache miss when a ScalarFile exists, hydrate GridCache from the
file once (deduped via the existing claim_fill primitive) and serve all
subsequent reads from ETS. Bound memory with prune_keep_latest/1 — keep
the 24 most-recently-touched valid_times, which covers a full HRRR run
(analysis + 18 forecasts) plus a few stragglers from the previous run.

In the steady state, the first viewport read for a given forecast hour
takes one full ScalarFile decode (~50-100 ms for 92k cells); every
subsequent tile, point lookup, and viewport read for the same hour
serves from ETS in <1 ms.
2026-04-29 13:10:12 -05:00

280 lines
8.6 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.GridCache do
@moduledoc """
Node-local ETS cache of derived HRRR grid rows keyed by `valid_time`. Mirrors
`Microwaveprop.Propagation.ScoreCache` but for the `/weather` map.
The Weather map LiveView calls `latest_weather_grid/1` on mount and every
pan/zoom. Each call otherwise hits the 42M-row partitioned `hrrr_profiles`
table, runs per-row `derive_and_clean` transforms, and returns 3-10k rows.
With this cache those calls become in-memory map iterations.
## Storage layout
Each cache entry is bucketed into 5°×5° spatial chunks matching
`Microwaveprop.Weather.ScalarFile`'s on-disk layout. The ETS value for
`valid_time` is `%{{lat_band, lon_band} => %{{lat, lon} => row}}`. That
way `fetch_bounds/2` only walks the chunks that intersect the requested
viewport instead of the full 92k-cell CONUS map. `fetch_point/3` reads
exactly one chunk.
Populated by `Microwaveprop.Weather.warm_grid_cache/1` after the hourly
worker upserts new HRRR data, fanned out across the cluster via the
`"weather:cache"` PubSub topic so every node stays in sync.
"""
use GenServer
alias Phoenix.PubSub
@table :weather_grid_cache
@lock_table :weather_grid_fill_locks
@topic "weather:cache"
@pubsub Microwaveprop.PubSub
@chunk_step 5
@type row :: %{required(:lat) => float(), required(:lon) => float(), optional(atom()) => any()}
@type bounds :: %{optional(String.t()) => float()}
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@spec fetch(DateTime.t()) :: {:ok, [row()]} | :miss
def fetch(valid_time) do
case :ets.lookup(@table, valid_time) do
[{_, chunked}] ->
emit_lookup(true)
{:ok, chunks_to_list(chunked)}
[] ->
emit_lookup(false)
:miss
end
end
@spec fetch_bounds(DateTime.t(), bounds() | nil) :: {:ok, [row()]} | :miss
def fetch_bounds(valid_time, bounds) do
case :ets.lookup(@table, valid_time) do
[{_, chunked}] ->
emit_lookup(true)
{:ok, chunks_filtered_to_list(chunked, bounds)}
[] ->
emit_lookup(false)
:miss
end
end
@spec fetch_point(DateTime.t(), float(), float()) :: {:ok, row()} | :miss
def fetch_point(valid_time, lat, lon) do
case :ets.lookup(@table, valid_time) do
[{_, chunked}] ->
chunk_key = chunk_key_for(lat, lon)
case chunked |> Map.get(chunk_key, %{}) |> Map.get({lat, lon}) do
nil ->
emit_lookup(false)
:miss
row ->
emit_lookup(true)
{:ok, row}
end
[] ->
emit_lookup(false)
:miss
end
end
defp emit_lookup(hit) do
:telemetry.execute([:microwaveprop, :weather, :grid_cache, :lookup], %{}, %{hit: hit})
end
@spec put(DateTime.t(), [row()]) :: :ok
def put(valid_time, rows) do
chunked = list_to_chunks(rows)
:ets.insert(@table, {valid_time, chunked})
:ok
end
@doc "Insert locally AND broadcast to peer nodes via PubSub."
@spec broadcast_put(DateTime.t(), [row()]) :: :ok
def broadcast_put(valid_time, rows) do
_ = PubSub.broadcast(@pubsub, @topic, {:weather_cache_refresh, valid_time, rows})
:ok
end
@spec latest_valid_time() :: DateTime.t() | nil
def latest_valid_time do
match_spec = [{{:"$1", :_}, [], [:"$1"]}]
case :ets.select(@table, match_spec) do
[] -> nil
times -> Enum.max(times, DateTime)
end
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 """
Keep only the `keep` most-recent valid_times, dropping the rest. Returns
the number of entries removed. Used to bound memory when forecast hours
are cached on demand.
"""
@spec prune_keep_latest(non_neg_integer()) :: non_neg_integer()
def prune_keep_latest(keep) when is_integer(keep) and keep >= 0 do
case :ets.select(@table, [{{:"$1", :_}, [], [:"$1"]}]) do
[] ->
0
times ->
sorted = Enum.sort(times, {:desc, DateTime})
to_drop = Enum.drop(sorted, keep)
Enum.each(to_drop, &:ets.delete(@table, &1))
length(to_drop)
end
end
@spec clear() :: :ok
def clear do
GenServer.call(__MODULE__, :clear)
end
@doc """
Atomically claim the right to fill the cache for `valid_time`. Returns
`true` if this caller won the claim and should run the fill; `false` if
another caller is already filling. Prevents N concurrent /weather mounts
after a pod restart from each firing the 15-second cold-fill read and
starving the Postgres connection pool.
The GenServer `Process.monitor`s the caller: if the caller crashes
before calling `release_fill/1`, the lock is released automatically.
"""
@spec claim_fill(term()) :: boolean()
def claim_fill(valid_time) do
GenServer.call(__MODULE__, {:claim_fill, valid_time, self()})
end
@doc "Release a fill lock claimed via `claim_fill/1`."
@spec release_fill(term()) :: :ok
def release_fill(valid_time) do
GenServer.call(__MODULE__, {:release_fill, valid_time})
end
@spec sync() :: :ok
def sync do
GenServer.call(__MODULE__, :sync)
end
@impl true
def init(_opts) do
_ = :ets.new(@table, [:set, :named_table, :public, :compressed, read_concurrency: true])
_ = :ets.new(@lock_table, [:set, :named_table, :protected])
:ok = PubSub.subscribe(@pubsub, @topic)
{:ok, %{monitors: %{}}}
end
@impl true
def handle_call(:sync, _from, state), do: {:reply, :ok, state}
def handle_call(:clear, _from, state) do
:ets.delete_all_objects(@table)
# Demonitor all tracked callers and drop every lock so tests start clean.
for {ref, _vt} <- state.monitors, do: Process.demonitor(ref, [:flush])
:ets.delete_all_objects(@lock_table)
{:reply, :ok, %{state | monitors: %{}}}
end
def handle_call({:claim_fill, valid_time, caller}, _from, state) do
if :ets.insert_new(@lock_table, {valid_time, caller}) do
ref = Process.monitor(caller)
{:reply, true, %{state | monitors: Map.put(state.monitors, ref, valid_time)}}
else
{:reply, false, state}
end
end
def handle_call({:release_fill, valid_time}, _from, state) do
{monitors, _matched} = pop_monitor_for(state.monitors, valid_time)
:ets.delete(@lock_table, valid_time)
{:reply, :ok, %{state | monitors: monitors}}
end
@impl true
def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do
case Map.pop(state.monitors, ref) do
{nil, _} ->
{:noreply, state}
{valid_time, monitors} ->
:ets.delete(@lock_table, valid_time)
{:noreply, %{state | monitors: monitors}}
end
end
def handle_info({:weather_cache_refresh, valid_time, rows}, state) do
put(valid_time, rows)
{:noreply, state}
end
def handle_info(_msg, state), do: {:noreply, state}
defp pop_monitor_for(monitors, valid_time) do
case Enum.find(monitors, fn {_ref, vt} -> vt == valid_time end) do
nil ->
{monitors, nil}
{ref, ^valid_time} ->
Process.demonitor(ref, [:flush])
{Map.delete(monitors, ref), ref}
end
end
# ---------- Chunk helpers ----------
defp list_to_chunks(rows) do
Enum.reduce(rows, %{}, fn %{lat: lat, lon: lon} = row, acc ->
key = chunk_key_for(lat, lon)
chunk = Map.get(acc, key, %{})
Map.put(acc, key, Map.put(chunk, {lat, lon}, row))
end)
end
defp chunks_to_list(chunked) do
Enum.flat_map(chunked, fn {_chunk_key, cells} ->
Enum.map(cells, fn {_, row} -> row 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}, row} <- cells, lat >= s, lat <= n, lon >= w, lon <= e, do: row
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