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.
136 lines
3.9 KiB
Elixir
136 lines
3.9 KiB
Elixir
defmodule Microwaveprop.Weather.GridCacheTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
alias Microwaveprop.Weather.GridCache
|
|
|
|
setup do
|
|
GridCache.clear()
|
|
:ok
|
|
end
|
|
|
|
describe "fetch/1" do
|
|
test "returns :miss when nothing is cached" do
|
|
assert GridCache.fetch(~U[2026-04-12 12:00:00Z]) == :miss
|
|
end
|
|
|
|
test "returns cached rows after put" do
|
|
rows = [%{lat: 32.0, lon: -97.0, temperature: 25.0}]
|
|
GridCache.put(~U[2026-04-12 12:00:00Z], rows)
|
|
assert {:ok, [%{lat: 32.0, lon: -97.0}]} = GridCache.fetch(~U[2026-04-12 12:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "fetch_bounds/2" do
|
|
setup do
|
|
rows = [
|
|
%{lat: 32.0, lon: -97.0, temperature: 25.0},
|
|
%{lat: 40.0, lon: -74.0, temperature: 20.0},
|
|
%{lat: 34.0, lon: -98.0, temperature: 28.0}
|
|
]
|
|
|
|
GridCache.put(~U[2026-04-12 12:00:00Z], rows)
|
|
:ok
|
|
end
|
|
|
|
test "returns all rows when bounds are nil" do
|
|
assert {:ok, list} = GridCache.fetch_bounds(~U[2026-04-12 12:00:00Z], nil)
|
|
assert length(list) == 3
|
|
end
|
|
|
|
test "returns only rows inside the bounds" do
|
|
bounds = %{"south" => 31.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
|
|
assert {:ok, list} = GridCache.fetch_bounds(~U[2026-04-12 12:00:00Z], bounds)
|
|
assert length(list) == 2
|
|
end
|
|
|
|
test "returns :miss when the valid_time is not cached" do
|
|
bounds = %{"south" => 0.0, "north" => 90.0, "west" => -180.0, "east" => 0.0}
|
|
assert GridCache.fetch_bounds(~U[2099-01-01 00:00:00Z], bounds) == :miss
|
|
end
|
|
end
|
|
|
|
describe "fetch_point/3" do
|
|
setup do
|
|
rows = [
|
|
%{lat: 32.0, lon: -97.0, temperature: 25.0},
|
|
%{lat: 33.0, lon: -97.0, temperature: 27.0}
|
|
]
|
|
|
|
GridCache.put(~U[2026-04-12 12:00:00Z], rows)
|
|
:ok
|
|
end
|
|
|
|
test "returns the cached row for a known point" do
|
|
assert {:ok, %{temperature: 25.0}} =
|
|
GridCache.fetch_point(~U[2026-04-12 12:00:00Z], 32.0, -97.0)
|
|
end
|
|
|
|
test "returns :miss for an unknown point" do
|
|
assert GridCache.fetch_point(~U[2026-04-12 12:00:00Z], 40.0, -74.0) == :miss
|
|
end
|
|
end
|
|
|
|
describe "claim_fill/1 and release_fill/1" do
|
|
@valid_time ~U[2026-04-21 12:00:00Z]
|
|
|
|
test "first claimer wins, subsequent claimers see :in_progress" do
|
|
assert GridCache.claim_fill(@valid_time) == true
|
|
assert GridCache.claim_fill(@valid_time) == false
|
|
end
|
|
|
|
test "release_fill allows a re-claim" do
|
|
assert GridCache.claim_fill(@valid_time) == true
|
|
:ok = GridCache.release_fill(@valid_time)
|
|
assert GridCache.claim_fill(@valid_time) == true
|
|
end
|
|
|
|
test "lock is auto-released when the claimer process crashes" do
|
|
parent = self()
|
|
|
|
{:ok, claimer} =
|
|
Task.start(fn ->
|
|
true = GridCache.claim_fill(@valid_time)
|
|
send(parent, :claimed)
|
|
# Block until we're killed
|
|
Process.sleep(:infinity)
|
|
end)
|
|
|
|
receive do
|
|
:claimed -> :ok
|
|
after
|
|
1000 -> flunk("claimer never signaled")
|
|
end
|
|
|
|
# Lock is held by the crashed process
|
|
assert GridCache.claim_fill(@valid_time) == false
|
|
|
|
# Kill the claimer
|
|
ref = Process.monitor(claimer)
|
|
Process.exit(claimer, :kill)
|
|
|
|
receive do
|
|
{:DOWN, ^ref, :process, ^claimer, _} -> :ok
|
|
after
|
|
1000 -> flunk("claimer didn't die")
|
|
end
|
|
|
|
# Flush the GenServer so the :DOWN handler runs before we re-probe ETS.
|
|
:ok = GridCache.sync()
|
|
|
|
assert GridCache.claim_fill(@valid_time) == true
|
|
end
|
|
end
|
|
|
|
describe "latest_valid_time/0" do
|
|
test "returns the most recent cached valid_time" do
|
|
GridCache.put(~U[2026-04-12 10:00:00Z], [])
|
|
GridCache.put(~U[2026-04-12 14:00:00Z], [])
|
|
GridCache.put(~U[2026-04-12 12:00:00Z], [])
|
|
assert GridCache.latest_valid_time() == ~U[2026-04-12 14:00:00Z]
|
|
end
|
|
|
|
test "returns nil when nothing is cached" do
|
|
assert GridCache.latest_valid_time() == nil
|
|
end
|
|
end
|
|
end
|