fix(backfill): re-probe :unavailable contacts after a 24 h cooldown

The Contact list's 'Partial' badge was sticky: once a worker gave up on
a contact (upstream 404, no nearby station, IEM throttling) or the
stale-queued reconciler flipped it to :unavailable after 3 days, the
backfill cron never looked at it again. Upstream archives heal over
time — IEM backfills gaps, new ASOS stations come online, Rust worker
gains capability — so rows stayed 'Partial' that shouldn't have.

Changes:

* BackfillEnqueueWorker.type_filter now also matches <status>_status =
  :unavailable when contacts.updated_at is older than a 24 h cooldown.
  enqueue_for_contact is idempotent: if there's still nothing to
  fetch it'll flip the status to :complete on the empty-jobs branch;
  if new data shows up it builds fresh jobs.
* reconcile_stale_queued_to_unavailable now bumps updated_at when
  flipping to :unavailable so the cooldown starts fresh — otherwise
  the main loop would pick it right back up on the same cron tick
  (stale updated_at < any reasonable cooldown) and immediately
  transition :unavailable → :complete, defeating the :unavailable
  signal entirely.

Also:

* Drop ScoresFile.point_score/3, dead since read_point moved to the
  pread fast path in commit bd3b114.
* Clear Microwaveprop.Cache in WeatherGridTest setup. The new
  ProfilesFile read cache is keyed by (base_dir, valid_time); tests
  in this file reuse DateTime.utc_now() truncated to seconds and
  share the default base_dir, so a prior test's cached read leaks
  into the next one's expectations.
This commit is contained in:
Graham McIntire 2026-04-20 16:46:32 -05:00
parent 796fb22d4b
commit e641fce278
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 99 additions and 15 deletions

View file

@ -409,19 +409,6 @@ defmodule Microwaveprop.Propagation.ScoresFile do
defp clamp(n, _lo, hi) when n > hi, do: hi
defp clamp(n, _lo, _hi), do: n
defp point_score(payload, lat, lon) do
%{lat_min: lat_min, lon_min: lon_min, step: step, n_rows: n_rows, n_cols: n_cols, scores: body} = payload
row = round((lat - lat_min) / step)
col = round((lon - lon_min) / step)
if row in 0..(n_rows - 1) and col in 0..(n_cols - 1) do
case :binary.at(body, row * n_cols + col) do
@no_data -> nil
score -> score
end
end
end
defp max_datetime([]), do: nil
defp max_datetime(times), do: Enum.max(times, DateTime)

View file

@ -15,6 +15,17 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
require Logger
@enrichable [:pending, :queued, :failed]
# How long a contact must sit at :unavailable before the backfill
# will re-probe it. :unavailable is reached either because a worker
# gave up (upstream 404/5xx, no nearby station, pre-2014 archive
# gap) or because the stale-queued reconciler flipped it after 3
# days of :queued without data. Upstream conditions change over
# time (new ASOS stations, IEM backfills, Rust worker new
# capabilities), so "Partial" contacts must eventually get a second
# look — without pounding the upstream every 30 min. 24 h strikes
# a balance.
@unavailable_reprobe_cooldown_seconds 86_400
@valid_types ~w(hrrr weather terrain iemre narr radar mechanism)
# Virtual types have no <type>_status column on contacts; they're synthesized
@ -121,15 +132,23 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
|> DateTime.add(-@stale_queued_cutoff_seconds, :second)
|> DateTime.truncate(:second)
now = DateTime.truncate(DateTime.utc_now(), :second)
types
|> Enum.reject(&(&1 in @virtual_types))
|> Enum.reduce(0, fn type, acc ->
field = status_column(type)
# Bump updated_at to now when flipping so the
# @unavailable_reprobe_cooldown window starts fresh — otherwise
# the main loop's :unavailable-reprobe filter would pick this
# contact back up on the same cron tick (stale timestamp < any
# reasonable cooldown) and immediately mark it :complete on the
# empty-jobs branch, defeating the point of flagging :unavailable.
{n, _} =
Contact
|> where([c], field(c, ^field) == :queued and c.updated_at < ^cutoff)
|> Repo.update_all(set: [{field, :unavailable}])
|> Repo.update_all(set: [{field, :unavailable}, {:updated_at, now}])
acc + n
end)
@ -158,6 +177,11 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
end
defp type_filter(types) do
reprobe_cutoff =
DateTime.utc_now()
|> DateTime.add(-@unavailable_reprobe_cooldown_seconds, :second)
|> DateTime.truncate(:second)
Enum.reduce(types, dynamic(false), fn
:narr, acc ->
# NARR targets pre-2014 contacts where HRRR is unavailable. The NCEI
@ -169,7 +193,14 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
type, acc ->
field = status_column(type)
dynamic([c], ^acc or field(c, ^field) in ^@enrichable)
# Include :unavailable rows older than the cooldown so "Partial"
# contacts get another chance when upstream archives heal.
dynamic(
[c],
^acc or field(c, ^field) in ^@enrichable or
(field(c, ^field) == :unavailable and c.updated_at < ^reprobe_cutoff)
)
end)
end
end

View file

@ -11,6 +11,11 @@ defmodule Microwaveprop.WeatherGridTest do
# test sandbox). Clear it so each test gets a fresh cache and the DB load
# path actually runs.
GridCache.clear()
# Microwaveprop.Cache now memoizes ProfilesFile.read/1 keyed by
# `(base_dir, valid_time)`. Tests within this file share the default
# base_dir and frequently reuse `DateTime.utc_now()` truncated to seconds,
# so a prior test's cached read leaks into the next one's assertion.
Microwaveprop.Cache.clear()
:ok
end

View file

@ -138,6 +138,67 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
assert_receive {:enqueue_complete, 1}
end
# :unavailable is a terminal state in the sense that workers don't
# retry it, but the stale-queued reconciler flips :queued → :unavailable
# after 3 days when no data has landed — which means a contact can end
# up "Partial" forever even if the upstream archive later starts
# serving data (new ASOS station in range, re-enabled IEM endpoint).
# The backfill must re-probe :unavailable contacts so they get another
# shot at enrichment; otherwise the contact list shows "Partial" for
# rows that haven't been touched in months.
test "re-selects :unavailable contacts older than the cooldown for another shot at enrichment" do
import Ecto.Query
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
contact = create_contact()
contact
|> Ecto.Changeset.change(%{
hrrr_status: :complete,
weather_status: :unavailable,
terrain_status: :complete,
iemre_status: :complete
})
|> Repo.update!()
# Force updated_at past the 24 h cooldown so the type_filter's
# :unavailable branch picks this contact up.
old = DateTime.utc_now() |> DateTime.add(-2 * 86_400, :second) |> DateTime.truncate(:second)
Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: [updated_at: old])
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert_receive {:enqueue_complete, 1}
end
test "leaves :unavailable contacts inside the cooldown window alone" do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
contact = create_contact()
contact
|> Ecto.Changeset.change(%{
hrrr_status: :complete,
weather_status: :unavailable,
terrain_status: :complete,
iemre_status: :complete
})
|> Repo.update!()
# updated_at is fresh (right now) — inside the 24 h cooldown.
# Contact must not be re-probed yet.
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert_receive {:enqueue_complete, 0}
end
# :narr is a virtual type (no narr_status column) — it targets contacts
# whose hrrr_status is :unavailable. BackfillEnqueueWorker must not try
# to reconcile a non-existent narr_status column or use it for priority