prop/test/microwaveprop/propagation/freshness_monitor_test.exs
Graham McIntire bba860f28d
fix(propagation): dedup PropagationGridWorker enqueues
FreshnessMonitor's comment claimed 'Oban unique constraint on
PropagationGridWorker prevents duplicates' — but the worker declared
no unique: option. During a long outage the monitor's 5-min stale-check
tick would pile up identical jobs (a 2h outage = 24 stacked jobs, each
doing the full f00-f18 HRRR chain).

Put the dedup on the worker (vs. the monitor's insert call) so anything
enqueuing it benefits — FreshnessMonitor, the hourly cron, or a manual
mix propagation_grid run.

unique: [period: 3600, states: [:available, :scheduled, :executing,
:retryable]] aligns with the hourly cron cadence. The seed job args
(%{}) collapse with themselves while chain-step jobs keep distinct
run_time/forecast_hour and remain enqueuable.
2026-04-16 14:58:55 -05:00

165 lines
6.4 KiB
Elixir

defmodule Microwaveprop.Propagation.FreshnessMonitorTest do
@moduledoc """
Characterization tests for `FreshnessMonitor`.
The monitor is a simple GenServer that polls propagation score
freshness on a 5-minute timer. On each tick it reads
`Propagation.latest_valid_time/0` (which reads `ScoresFile`
on disk) and enqueues a `PropagationGridWorker` job when either
no scores exist yet or the newest score file is older than
120 minutes.
These tests drive the tick path synchronously by invoking
`handle_info(:check, state)` from the test process inside
`Oban.Testing.with_testing_mode(:manual, ...)` — that keeps
`Oban.insert` from executing the job inline, letting us assert
on enqueued jobs via `all_enqueued/1`. One smoke test exercises
the supervised startup path with a fresh ScoresFile so no enqueue
is attempted.
The interval (`@check_interval = 5 min`) and staleness threshold
(`@stale_threshold_minutes = 120`) are compile-time module
attributes with no runtime override, so we can't shrink the
interval — we exercise the tick directly instead.
"""
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
alias Microwaveprop.Propagation.FreshnessMonitor
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Workers.PropagationGridWorker
@band_mhz 10_000
@sample_scores [%{lat: 25.0, lon: -125.0, score: 50}]
defp minutes_ago(n) do
DateTime.utc_now()
|> DateTime.add(-n, :minute)
|> DateTime.truncate(:second)
end
describe "handle_info(:check, state) — characterization of tick behavior" do
test "fresh grid: does not enqueue a PropagationGridWorker job" do
ScoresFile.write!(@band_mhz, minutes_ago(15), @sample_scores)
Oban.Testing.with_testing_mode(:manual, fn ->
assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{})
assert [] = all_enqueued(worker: PropagationGridWorker)
end)
end
test "stale grid (>120m old): enqueues a PropagationGridWorker job" do
ScoresFile.write!(@band_mhz, minutes_ago(180), @sample_scores)
Oban.Testing.with_testing_mode(:manual, fn ->
assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{})
[job] = all_enqueued(worker: PropagationGridWorker)
assert job.args == %{}
assert job.queue == "propagation"
end)
end
test "just past threshold (121m old): enqueues" do
ScoresFile.write!(@band_mhz, minutes_ago(121), @sample_scores)
Oban.Testing.with_testing_mode(:manual, fn ->
assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{})
assert [_job] = all_enqueued(worker: PropagationGridWorker)
end)
end
test "exactly at threshold (120m old): does NOT enqueue (strict `>` comparison)" do
ScoresFile.write!(@band_mhz, minutes_ago(120), @sample_scores)
Oban.Testing.with_testing_mode(:manual, fn ->
assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{})
assert [] = all_enqueued(worker: PropagationGridWorker)
end)
end
test "empty grid (no score files): enqueues a PropagationGridWorker job" do
# DataCase.reset_score_files/0 wipes the scores dir, so nothing exists.
Oban.Testing.with_testing_mode(:manual, fn ->
assert {:noreply, %{}} = FreshnessMonitor.handle_info(:check, %{})
[job] = all_enqueued(worker: PropagationGridWorker)
assert job.args == %{}
end)
end
test "returns unchanged state map from the tick" do
Oban.Testing.with_testing_mode(:manual, fn ->
assert {:noreply, %{foo: :bar}} = FreshnessMonitor.handle_info(:check, %{foo: :bar})
end)
end
test "repeated ticks on a persistently stale grid are deduplicated into a single job" do
# PropagationGridWorker declares `unique:` on the seed args (`%{}`),
# so a stale-check tick that runs while an earlier seed is still
# available/scheduled/executing/retryable is a no-op. Without this,
# a long outage stacks one expensive HRRR f00-f18 chain per 5-min
# tick (e.g. a 2-hour outage = 24 redundant seed jobs).
ScoresFile.write!(@band_mhz, minutes_ago(180), @sample_scores)
Oban.Testing.with_testing_mode(:manual, fn ->
FreshnessMonitor.handle_info(:check, %{})
FreshnessMonitor.handle_info(:check, %{})
FreshnessMonitor.handle_info(:check, %{})
assert 1 == length(all_enqueued(worker: PropagationGridWorker))
end)
end
test "tick does not synchronously deliver the next :check message (it is scheduled with a 5-minute delay)" do
# Characterizes the `Process.send_after(self(), :check, @check_interval)`
# call at the end of handle_info/2: the follow-up :check is
# scheduled, not sent immediately, so the test mailbox stays
# empty.
ScoresFile.write!(@band_mhz, minutes_ago(15), @sample_scores)
Oban.Testing.with_testing_mode(:manual, fn ->
FreshnessMonitor.handle_info(:check, %{})
refute_received :check
end)
end
end
describe "start_link/1 — supervision lifecycle" do
test "does not start when start_freshness_monitor config is false (test default)" do
# In test.exs the flag is false, so start_link returns :ignore
# and no process is registered.
assert :ignore = FreshnessMonitor.start_link([])
assert Process.whereis(FreshnessMonitor) == nil
end
test "supervised start: boots cleanly with a fresh ScoresFile and no enqueue occurs" do
ScoresFile.write!(@band_mhz, minutes_ago(15), @sample_scores)
# Enable the start flag for this test only.
original = Application.get_env(:microwaveprop, :start_freshness_monitor)
Application.put_env(:microwaveprop, :start_freshness_monitor, true)
on_exit(fn ->
if is_nil(original) do
Application.delete_env(:microwaveprop, :start_freshness_monitor)
else
Application.put_env(:microwaveprop, :start_freshness_monitor, original)
end
end)
pid = start_supervised!(FreshnessMonitor)
assert is_pid(pid)
assert Process.alive?(pid)
assert Process.whereis(FreshnessMonitor) == pid
# Force the init-time :check to be processed by issuing a
# synchronous call; GenServer.call flushes the mailbox up to
# this point. FreshnessMonitor doesn't implement handle_call,
# so use :sys.get_state instead — it waits for all pending
# messages to be handled.
assert %{} == :sys.get_state(pid)
end
end
end