prop/lib/microwaveprop/workers/pskr_calibration_worker.ex
Graham McIntire 581955bd69
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled
fix: prevent contacts_dedup_idx collisions in parallel async tests
Create shared ContactsFixtures module with globally-unique qso_timestamp
seconds to prevent unique-constraint violations when async test modules
run in parallel and insert contacts with identical dedup-key columns.
The qso_timestamp column is timestamp(0), so microsecond offsets were
truncated — use System.unique_integer monotonic seconds instead.

Also make count-asserting tests resilient to sandbox-leaked contacts
from prior tests by using >= assertions or status-based checks rather
than exact counts.

Includes automated DateTime.add → DateTime.shift migration from
mix format.
2026-08-04 17:05:16 -05:00

134 lines
4.9 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.Pro.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: :incomplete
]
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.Pro.Worker
def process(%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 process(%Oban.Job{args: args}) do
lookback = lookback_hours(args)
now = truncate_to_hour(DateTime.utc_now())
Enum.each(0..lookback, fn offset ->
hour = DateTime.shift(now, hour: -offset)
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