prop/lib/microwaveprop/workers/pskr_calibration_worker.ex
Graham McIntire fe2ad2df15
fix(pskr): self-healing follow-up sampler runs for hours with missing HRRR
The producer-only path left older hours stuck at 0% HRRR coverage
forever once their fetch tasks drained: the rolling-window cron only
re-passes the prior hour, so manual lookback runs (or any
out-of-window hour) wrote NULL samples + enqueued tasks, then never
re-ran to UPSERT the now-landed HRRR data.

Fix: split the responsibility cleanly.

* CalibrationSampler.build_for_hour/1 now returns
  %{upserted: int, missing_hrrr_cells: int} so callers know whether
  the producer enqueued anything (i.e. whether a follow-up makes
  sense). Spec-tightened with a new @type result.
* PskrCalibrationWorker, after each hour's sampler pass, schedules a
  follow-up of itself for that exact hour 10 min later when missing
  > 0. The follow-up carries args %{"hour_utc" => ..., "_follow_up"
  => true}; the sentinel prevents follow-ups from chaining further
  follow-ups (rolling-window cron is the safety net).

Tests:
* Updated 4 existing sampler tests to the new map return shape.
* Moved the follow-up assertions from the sampler suite into the
  worker suite where they belong (sampler is now scheduling-free).
* Added 3 worker tests: schedules-when-missing, no-schedule-when-
  covered, follow-up-doesn't-chain.

3313 tests + 228 properties, 0 failures.
2026-05-05 18:00:10 -05:00

134 lines
5 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Workers.PskrCalibrationWorker do
@moduledoc """
Cron worker that builds `Pskr.CalibrationSample` rows by joining
PSKR spot density with HRRR atmospheric state and SWPC space
weather.
## Why frequent + rolling window
The naive design — one cron fire per hour, processing only the
previous hour — has two failure modes:
1. The whole hour's worth of cells is built in a single batch.
During peaks (50k+ spots/hour) one batch holds the DB pool
long enough to look like a backlog.
2. A pod restart between fires drops a full hour because the
next cron only catches the most-recent-prev hour.
Instead we fire every 5 minutes and process the **current hour
plus one prior hour** by default. Idempotent UPSERT on
`(hour_utc, band, midpoint_lat, midpoint_lon)` means each pass
merges new spots into existing rows; the work spreads across the
hour as spots arrive, and the boundary always has at least one
cron pass covering both sides without waiting for an hourly fire.
## Manual reruns
- `hour_utc` (ISO 8601) — process exactly that one hour. Uniqueness
is keyed on args, so manual reruns slot in alongside cron fires.
- `lookback_hours` (integer) — override the default window size.
Useful after longer outages: `%{"lookback_hours" => 24}` will
sweep the last day.
## Self-healing follow-ups
When the sampler enqueues `hrrr_fetch_tasks` (cells without HRRR
data), this worker schedules a follow-up of itself for that exact
hour ~10 min in the future via the `hour_utc` args path. The
follow-up's job is to UPSERT the now-landed HRRR data onto the
same calibration_samples rows.
The chain terminates naturally: once HRRR data is fully landed for
an hour, no fetch tasks are enqueued and no follow-up is scheduled.
Args carry a `_follow_up: true` sentinel on scheduled re-runs so a
follow-up that *itself* still finds missing data won't infinitely
schedule another (the Rust worker sometimes fails on HRRR publish-
lag for the current hour); the rolling-window cron remains the
safety net.
"""
use Oban.Worker,
queue: :backfill_enqueue,
max_attempts: 3,
unique: [
# Cron is every 5 min (300 s); 240 s window means the unique
# slot has expired by the time the next fire arrives. Manual
# reruns with explicit args use a different args fingerprint
# and bypass this check entirely.
period: 240,
states: [:available, :scheduled, :executing, :retryable]
]
alias Microwaveprop.Pskr.CalibrationSampler
require Logger
# Default rolling window: current hour + 1 prior. Wide enough to
# cover a 510 min cron interval crossing an hour boundary; not
# so wide that every fire burns DB time on hours that are already
# complete. Use `lookback_hours` to widen for catch-up.
@default_lookback_hours 1
# Delay before the self-scheduled follow-up sampler pass fires. 10
# min covers the typical Rust hrrr-point-worker drain latency for
# ~1k point batches.
@follow_up_delay_seconds 600
@impl Oban.Worker
def perform(%Oban.Job{args: %{"hour_utc" => iso} = args}) when is_binary(iso) do
{:ok, hour, _} = DateTime.from_iso8601(iso)
process_hour(hour, args)
:ok
end
def perform(%Oban.Job{args: args}) do
lookback = lookback_hours(args)
now = truncate_to_hour(DateTime.utc_now())
Enum.each(0..lookback, fn offset ->
hour = DateTime.add(now, -offset * 3600, :second)
process_hour(hour, args)
end)
:ok
end
defp lookback_hours(%{"lookback_hours" => n}) when is_integer(n) and n >= 0, do: n
defp lookback_hours(_), do: @default_lookback_hours
defp process_hour(hour, args) do
%{upserted: upserted, missing_hrrr_cells: missing} = CalibrationSampler.build_for_hour(hour)
if upserted > 0 do
Logger.info("PskrCalibrationWorker: upserted #{upserted} samples for #{hour}")
end
if missing > 0, do: maybe_schedule_follow_up(hour, args)
end
# Schedule one follow-up for this hour ~10 min out unless we're
# already on a follow-up. The `_follow_up: true` sentinel + the
# `hour_utc`-keyed args together make a unique fingerprint for the
# Oban unique constraint, so duplicate follow-ups for the same
# hour within 240 s are deduped automatically.
defp maybe_schedule_follow_up(_hour, %{"_follow_up" => true}), do: :ok
defp maybe_schedule_follow_up(hour, _args) do
%{"hour_utc" => DateTime.to_iso8601(hour), "_follow_up" => true}
|> __MODULE__.new(schedule_in: @follow_up_delay_seconds)
|> Oban.insert()
|> case do
{:ok, _job} ->
:ok
{:error, reason} ->
# Unique-conflict (same hour follow-up already pending) or
# transient DB error — either way the rolling-window cron is
# the safety net, so log and move on.
Logger.warning("PskrCalibrationWorker: failed to schedule follow-up for #{hour}: #{inspect(reason)}")
:ok
end
end
defp truncate_to_hour(dt), do: %{dt | minute: 0, second: 0, microsecond: {0, 0}}
end