1. GridCache: auto-release fill lock when the claimer process crashes.
claim_fill/1 + release_fill/1 go through the GenServer so the
server can Process.monitor the caller and clean up the ETS entry
on :DOWN. Clear/0 now resets both the data table and the lock
table. Fixes a latent bug where a crashed fill leaked the lock
indefinitely, preventing every subsequent /weather mount for that
valid_time from claiming and leaving cache cold.
2. RadarFrameWorker: distinguish permanent vs transient fetch errors.
404 from the IEM n0q archive is permanent (file will never exist)
and marks contacts :unavailable as before. Any other error shape
(5xx, timeout, transport failure) now returns {:error, reason}
so Oban retries — previously those also pinned contacts at
:unavailable after a transient outage.
3. AdminTaskWorker.native_derive: replace per-row Repo.update_all
(N round-trips + N fsyncs) with one UPDATE ... FROM unnest(...)
per 2000-row batch. For the 10k-profile budget this is one
network round trip per chunk instead of 10k, and one fsync per
chunk instead of 10k. Restructured the clause to separate
derivation (pure) from persistence (I/O).
All three changes are test-covered (grid_cache_test auto-release
test, radar_frame_worker_test 5xx + transport tests, existing
admin_task_worker_test native_derive coverage exercises the new
bulk path). Also drops the scorer_diff no-op test that was
verifying the clause removed in 61da51c.
206 lines
6.3 KiB
Elixir
206 lines
6.3 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
|
|
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(DateTime.t()) :: 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(DateTime.t()) :: :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])
|
|
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
|
|
|
|
# ---------- 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
|