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 -> maybe_enqueue(reason: "no scores found", age: nil) latest -> check_stale(latest) end end defp check_stale(latest) do age = DateTime.diff(DateTime.utc_now(), latest, :minute) if age > @stale_threshold_minutes do maybe_enqueue(reason: "scores #{age}m old", age: age) end end # 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. Only log when a new job actually lands; the steady-state # "still stale, still queued" case is uninteresting. defp maybe_enqueue(reason: reason, age: _age) do case Oban.insert(PropagationGridWorker.new(%{})) do {:ok, %Oban.Job{conflict?: false}} -> Logger.info("FreshnessMonitor: #{reason}, enqueued grid worker") {:ok, %Oban.Job{conflict?: true}} -> :ok {:error, reason} -> Logger.warning("FreshnessMonitor: enqueue failed: #{inspect(reason, printable_limit: 200)}") end end end