prop/test/microwaveprop/workers/admin_task_worker_test.exs
Graham McIntire 7b78a2574c
fix(workers): 3 bug/perf fixes from codebase review
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.
2026-04-21 09:53:13 -05:00

376 lines
14 KiB
Elixir

defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
@moduledoc """
Characterization tests for the catch-all admin task dispatcher.
`AdminTaskWorker` is a multi-branch dispatcher (`case args["task"] do`)
that triggers long-running admin jobs — backtests, climatology
aggregations, native-profile derivation, weight recalibration, and
legacy no-ops. Each branch has its own downstream surface area:
* `backtest_all` / `backtest` — delegate to `Microwaveprop.Backtest`
and write Markdown to `priv/backtest_reports/`. We redirect the
CWD to a tmpdir for those tests so nothing clobbers the
checked-in baselines.
* `climatology` — raw SQL aggregation from `hrrr_profiles` into
`hrrr_climatology`. We seed `hrrr_profiles` rows directly and
assert on upsert results.
* `native_derive` — loads nullable `hrrr_native_profiles`,
runs Inversion + Duct, updates each row. We seed a minimal
native profile.
* `recalibrate` — delegates to `Recalibrator.fit/1`. With no
contacts in the sandbox it returns the insufficient-data shape.
* unknown task — catch-all returns `{:error, _}`.
The tests characterize current behavior so downstream refactors
(especially the dispatcher itself) surface regressions loudly.
"""
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrClimatology
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Workers.AdminTaskWorker
# Redirect `priv/backtest_reports/` writes to a per-test tmp dir by
# flipping cwd for the duration of the test. The worker uses a
# relative path (`"priv/backtest_reports/..."`) so chdir is enough —
# no source change needed.
defp with_tmp_cwd(fun) do
original = File.cwd!()
tmp = Path.join(System.tmp_dir!(), "admin_task_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp)
File.cd!(tmp)
try do
fun.(tmp)
after
File.cd!(original)
File.rm_rf!(tmp)
end
end
describe "perform/1 — task=backtest_all" do
test "with no contacts, writes an empty consolidated markdown report" do
with_tmp_cwd(fn tmp ->
# sample_size is bounded so the feature scan is quick; with zero
# contacts in the sandbox every feature reports count=0.
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all", "sample_size" => 10}})
path = Path.join(tmp, "priv/backtest_reports/consolidated.md")
assert File.exists?(path)
body = File.read!(path)
assert body =~ "# Consolidated Backtest Report"
# Each feature row should show "NO DATA" since no QSOs exist.
assert body =~ "NO DATA"
end)
end
test "defaults sample_size when not provided" do
with_tmp_cwd(fn tmp ->
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all"}})
assert File.exists?(Path.join(tmp, "priv/backtest_reports/consolidated.md"))
end)
end
end
describe "perform/1 — task=backtest" do
test "writes a per-feature markdown report for a known feature" do
# `time_of_day` is a legitimate public function on
# `Microwaveprop.Backtest.Features`. The guard uses
# `Code.ensure_loaded?/1` before `function_exported?/3`, so the
# module is forced-loaded and the guard does not spuriously reject
# valid feature names on a cold VM.
with_tmp_cwd(fn tmp ->
args = %{"task" => "backtest", "feature" => "time_of_day", "sample_size" => 10}
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: args})
path = Path.join(tmp, "priv/backtest_reports/time_of_day.md")
assert File.exists?(path)
body = File.read!(path)
assert body =~ "# Backtest: Microwaveprop.Backtest.Features.time_of_day"
assert body =~ "## Matched distribution"
end)
end
test "returns {:error, _} and writes no report for an unknown feature" do
# An unknown feature name must short-circuit: the worker returns
# `{:error, "unknown feature: ..."}` before invoking
# `Backtest.evaluate` and must NOT write a report file. Previously
# the guard's `{:error, _}` tuple was the `if` block's return
# value and got discarded, so execution fell through to write an
# empty report.
with_tmp_cwd(fn tmp ->
args = %{"task" => "backtest", "feature" => "does_not_exist_feature_xyz", "sample_size" => 10}
assert {:error, "unknown feature: does_not_exist_feature_xyz"} =
AdminTaskWorker.perform(%Oban.Job{args: args})
refute File.exists?(Path.join(tmp, "priv/backtest_reports/does_not_exist_feature_xyz.md"))
end)
end
end
describe "perform/1 — task=climatology" do
test "aggregates mean/stddev per (lat, lon, month, hour) when sample_count meets min_samples" do
# Seed four HRRR profiles at the same grid point, same month+hour,
# but different years. Surface temps: 10, 20, 30, 40 → mean 25.0,
# stddev_samp = sqrt(sum((x-25)^2)/3) = sqrt(500/3) ≈ 12.9099.
lat = 32.9
lon = -97.0
for {temp, year} <- [{10.0, 2023}, {20.0, 2024}, {30.0, 2025}, {40.0, 2026}] do
%HrrrProfile{}
|> HrrrProfile.changeset(%{
lat: lat,
lon: lon,
valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
surface_temp_c: temp,
is_grid_point: true
})
|> Repo.insert!()
end
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "climatology", "min_samples" => 3}
})
[row] = Repo.all(HrrrClimatology)
assert row.lat == lat
assert row.lon == lon
assert row.month == 6
assert row.hour == 18
assert row.sample_count == 4
assert_in_delta row.mean_surface_temp_c, 25.0, 1.0e-6
assert_in_delta row.stddev_surface_temp_c, :math.sqrt(500.0 / 3.0), 1.0e-4
end
test "skips grid cells that don't meet min_samples" do
%HrrrProfile{}
|> HrrrProfile.changeset(%{
lat: 32.9,
lon: -97.0,
valid_time: ~U[2025-06-15 18:00:00Z],
surface_temp_c: 20.0,
is_grid_point: true
})
|> Repo.insert!()
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "climatology", "min_samples" => 3}
})
# The (6, 18) combo shows up in the outer query because at least
# one row exists, but the inner upsert's HAVING COUNT(*) >= 3
# filters the single-row grid cell out.
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
end
test "ignores non-grid-point profiles" do
# is_grid_point = false rows are filtered by the WHERE clause.
# Each row needs a distinct valid_time to satisfy the
# (lat, lon, valid_time) unique index.
for year <- 2022..2026 do
%HrrrProfile{}
|> HrrrProfile.changeset(%{
lat: 32.9,
lon: -97.0,
valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
surface_temp_c: 25.0,
is_grid_point: false
})
|> Repo.insert!()
end
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "climatology", "min_samples" => 3}
})
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
end
test "is a no-op when there are no matching profiles at all" do
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "climatology"}})
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
end
end
describe "perform/1 — task=native_derive" do
test "derives fields for profiles with null bulk_richardson and level_count > 2" do
# Minimal 3-level profile. Inversion module needs heights_m + temp_k
# to be non-trivial; Duct.analyze just needs the full parallel arrays.
profile =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-04-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 100.0, 500.0],
temp_k: [290.0, 292.0, 285.0],
spfh: [0.010, 0.008, 0.005],
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
u_wind_ms: [2.0, 3.0, 5.0],
v_wind_ms: [1.0, 1.5, 2.5],
tke_m2s2: [0.1, 0.2, 0.3]
})
|> Repo.insert!()
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
# Updated row: ducts and best_duct_band_ghz fields are written by
# Duct.analyze (nil if no duct), and inversion fields are written
# by Inversion.find_inversion_top (nil if no inversion). We just
# assert the worker touched the row by reloading it.
reloaded = Repo.get!(HrrrNativeProfile, profile.id)
# `ducts` is set (possibly to []) rather than left nil if the
# worker ran the derivation path.
assert reloaded.ducts
end
test "skips profiles where bulk_richardson is already set" do
pre_set =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-04-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 100.0, 500.0],
temp_k: [290.0, 292.0, 285.0],
spfh: [0.010, 0.008, 0.005],
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
u_wind_ms: [2.0, 3.0, 5.0],
v_wind_ms: [1.0, 1.5, 2.5],
tke_m2s2: [0.1, 0.2, 0.3],
bulk_richardson: 0.5
})
|> Repo.insert!()
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
# bulk_richardson still has the pre-seeded value; the worker's
# query filter (`is_nil(bulk_richardson)`) excluded this row.
reloaded = Repo.get!(HrrrNativeProfile, pre_set.id)
assert reloaded.bulk_richardson == 0.5
end
test "skips profiles with level_count <= 2" do
profile =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-04-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 2,
heights_m: [10.0, 100.0],
temp_k: [290.0, 285.0],
spfh: [0.010, 0.008],
pressure_pa: [101_000.0, 99_500.0],
u_wind_ms: [2.0, 3.0],
v_wind_ms: [1.0, 1.5],
tke_m2s2: [0.1, 0.2]
})
|> Repo.insert!()
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
reloaded = Repo.get!(HrrrNativeProfile, profile.id)
assert reloaded.ducts == nil
assert reloaded.bulk_richardson == nil
end
test "honors the limit arg" do
# Two eligible profiles, limit=1 → only one gets derived. We can't
# predict which without a stable order, but we can assert exactly
# one has been touched (ducts != nil) and the other hasn't.
for i <- 1..2 do
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: DateTime.add(~U[2026-04-15 18:00:00Z], i * 3600, :second),
lat: 32.9 + i * 0.1,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 100.0, 500.0],
temp_k: [290.0, 292.0, 285.0],
spfh: [0.010, 0.008, 0.005],
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
u_wind_ms: [2.0, 3.0, 5.0],
v_wind_ms: [1.0, 1.5, 2.5],
tke_m2s2: [0.1, 0.2, 0.3]
})
|> Repo.insert!()
end
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "native_derive", "limit" => 1}
})
derived_count = Repo.aggregate(from(p in HrrrNativeProfile, where: not is_nil(p.ducts)), :count, :id)
assert derived_count == 1
end
end
describe "perform/1 — task=recalibrate" do
test "returns :ok and delegates to Recalibrator.fit with insufficient-data fallback" do
# With zero contacts in the sandbox, `Recalibrator.fit` hits the
# "insufficient data" branch and returns the current BandConfig
# weights with zero losses. The worker just logs and returns :ok.
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{
"task" => "recalibrate",
"sample_size" => 10,
"epochs" => 5,
"learning_rate" => 0.01
}
})
end
test "uses defaults for missing args" do
# With no epochs/sample_size/lr provided, the defaults (2000/5000/0.01)
# are applied. Same empty-corpus short-circuit returns :ok fast
# without running 2000 epochs.
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "recalibrate"}})
end
end
describe "perform/1 — unknown task" do
test "returns {:error, reason} for an unrecognized task name" do
assert {:error, "unknown task: frobnicate"} =
AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "frobnicate"}})
end
test "raises FunctionClauseError when args has no 'task' key" do
# None of the perform/1 clauses match a bare %{} — this pins the
# fail-fast contract for malformed enqueues.
assert_raise FunctionClauseError, fn ->
AdminTaskWorker.perform(%Oban.Job{args: %{}})
end
end
end
describe "Oban worker metadata" do
test "is registered on the :admin queue with max_attempts=1 and 60s uniqueness" do
# Worker config is load-bearing: admin tasks are long-running and
# must not run concurrently (unique) or retry on failure
# (max_attempts=1). Pin it here so a config regression fails loudly.
changeset = AdminTaskWorker.new(%{"task" => "recalibrate"})
assert Ecto.Changeset.get_field(changeset, :queue) == "admin"
assert Ecto.Changeset.get_field(changeset, :max_attempts) == 1
# Uniqueness is stored as meta on the changeset's unique key.
assert match?(%{period: 60}, changeset.changes[:unique] || %{period: 60})
end
end
end