defmodule Microwaveprop.Propagation.FreshnessMonitor do @moduledoc """ Monitors propagation score freshness and enqueues grid worker jobs when data is stale. Checks every 5 minutes. Covers missed cron ticks, slow deploys, worker crashes, and any other gap in hourly scoring. """ use GenServer alias Microwaveprop.Propagation alias Microwaveprop.Workers.PropagationGridWorker require Logger @check_interval to_timeout(minute: 5) @stale_threshold_minutes 120 @spec start_link(keyword()) :: GenServer.on_start() | :ignore def start_link(_opts) do if Application.get_env(:microwaveprop, :start_freshness_monitor, true) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) else :ignore end end @impl true def init(:ok) do send(self(), :check) {:ok, %{}} end @impl true def handle_info(:check, state) do check_freshness() Process.send_after(self(), :check, @check_interval) {:noreply, state} end defp check_freshness do case Propagation.latest_valid_time() do nil -> Logger.info("FreshnessMonitor: no scores found, enqueuing grid worker") enqueue_if_not_queued() latest -> age = DateTime.diff(DateTime.utc_now(), latest, :minute) if age > @stale_threshold_minutes do Logger.info("FreshnessMonitor: scores are #{age}m old, enqueuing grid worker") enqueue_if_not_queued() end end end defp enqueue_if_not_queued do # PropagationGridWorker declares `unique:` over a 1-hour window on # the seed args (`%{}`), so repeated 5-minute ticks during a long # outage collapse into a single seed job — not one stacked chain # per tick. `Oban.insert` returns `{:ok, job}` either way; the # `conflict?` field on the returned job distinguishes the two. Oban.insert(PropagationGridWorker.new(%{})) end end