prop/lib/microwaveprop/weather/grid_cache.ex
Graham McIntire a24ce027f4 Fix /weather 500 on cold cache, untrack k8s secret
The /weather LiveView was timing out on pod restart because
latest_weather_grid/1 ran load_weather_grid_from_db/1 synchronously on
cache miss — that query reads 176k hrrr_profiles rows with two huge
JSONB columns (profile array + duct_characteristics), and JSONB
decoding blocked the LiveView process past the 15-second pool
ownership timeout.

On cache miss latest_weather_grid/1 now returns empty immediately and
kicks off a deduped background fill through GridCache.claim_fill/1 (a
per-valid_time ETS lock so N concurrent mounts after restart don't each
fire the slow query). When the fill completes it broadcasts
weather:updated, and the handle_info in WeatherMapLive hits the warm
cache and pushes rows over the socket.

Added load_weather_grid/1 as a synchronous sibling for tests and any
caller that genuinely needs inline data. Tests updated to use it and
to clear GridCache in setup so ETS state doesn't leak between cases.

Also untrack k8s/secret.yaml and add both it and k8s/*-secret.yaml to
.gitignore — those manifests hold plaintext SMTP and database
credentials that should not live in the repo. Secrets already in git
history should be rotated separately.
2026-04-12 13:56:59 -05:00

158 lines
4.8 KiB
Elixir

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.
Each cache entry stores `%{{lat, lon} => derived_row}` so per-point lookups
(used by `weather_point_detail/3`) are O(1). 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
@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
[{_, grid}] -> {:ok, grid_to_list(grid)}
[] -> :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
[{_, grid}] -> {:ok, grid_to_filtered_list(grid, bounds)}
[] -> :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
[{_, grid}] ->
case Map.get(grid, {lat, lon}) do
nil -> :miss
row -> {:ok, row}
end
[] ->
:miss
end
end
@spec put(DateTime.t(), [row()]) :: :ok
def put(valid_time, rows) do
grid = list_to_grid(rows)
:ets.insert(@table, {valid_time, grid})
: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
@spec clear() :: :ok
def clear do
:ets.delete_all_objects(@table)
:ok
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 `load_weather_grid_from_db`
query and starving the Postgres connection pool.
"""
@spec claim_fill(DateTime.t()) :: boolean()
def claim_fill(valid_time) do
:ets.insert_new(@lock_table, {valid_time, :in_progress})
end
@doc "Release a fill lock claimed via `claim_fill/1`."
@spec release_fill(DateTime.t()) :: :ok
def release_fill(valid_time) do
:ets.delete(@lock_table, valid_time)
:ok
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, read_concurrency: true])
:ets.new(@lock_table, [:set, :named_table, :public])
PubSub.subscribe(@pubsub, @topic)
{:ok, %{}}
end
@impl true
def handle_call(:sync, _from, state), do: {:reply, :ok, state}
@impl true
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}
# ---------- Internal ----------
defp list_to_grid(rows) do
Map.new(rows, fn %{lat: lat, lon: lon} = row -> {{lat, lon}, row} end)
end
defp grid_to_list(grid), do: Enum.map(grid, fn {_, row} -> row end)
defp grid_to_filtered_list(grid, nil), do: grid_to_list(grid)
defp grid_to_filtered_list(grid, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
grid
|> Enum.filter(fn {{lat, lon}, _} ->
lat >= s and lat <= n and lon >= w and lon <= e
end)
|> Enum.map(fn {_, row} -> row end)
end
end