Give up on stuck :queued backfill contacts after 3 days
Contacts whose weather/hrrr/terrain/iemre status has been :queued for more than 3 days (144 cron cycles) are flipped to :unavailable. The set_enrichment_status!/3 helper is a no-op when the status is unchanged, so a stuck row retains a stale updated_at — which is exactly the signal we need for 'the cron has already tried and the data doesnt exist upstream' (dead ASOS station, pre-2014 HRRR, out-of-grid IEMRE). Clears ~1,492 pre-existing stuck contacts and stops them from churning the backfill queue.
This commit is contained in:
parent
62a7de3113
commit
79e16ae9d3
2 changed files with 152 additions and 0 deletions
|
|
@ -16,6 +16,15 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
|
||||||
@enrichable [:pending, :queued, :failed]
|
@enrichable [:pending, :queued, :failed]
|
||||||
@valid_types ~w(hrrr weather terrain iemre era5)
|
@valid_types ~w(hrrr weather terrain iemre era5)
|
||||||
|
|
||||||
|
# A :queued contact whose updated_at is older than this is one the cron has
|
||||||
|
# already tried ~144 times over 3 days without the underlying worker landing
|
||||||
|
# data. At that point the data is effectively unreachable (dead ASOS station,
|
||||||
|
# pre-2014 HRRR, out-of-grid IEMRE) and the cron should stop re-enqueueing.
|
||||||
|
# set_enrichment_status!/3 is a no-op when the status is unchanged, so a
|
||||||
|
# stuck :queued row retains a stale updated_at across cron cycles — which is
|
||||||
|
# exactly what we need as the "given up" signal.
|
||||||
|
@stale_queued_cutoff_seconds 3 * 86_400
|
||||||
|
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"limit" => limit} = args}) do
|
def perform(%Oban.Job{args: %{"limit" => limit} = args}) do
|
||||||
types = parse_types(args)
|
types = parse_types(args)
|
||||||
|
|
@ -26,6 +35,12 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
|
||||||
Logger.info("BackfillEnqueue: reconciled #{reconciled} stale queued contacts")
|
Logger.info("BackfillEnqueue: reconciled #{reconciled} stale queued contacts")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
stale_unavail = reconcile_stale_queued_to_unavailable(types)
|
||||||
|
|
||||||
|
if stale_unavail > 0 do
|
||||||
|
Logger.info("BackfillEnqueue: marked #{stale_unavail} stuck-queued contacts as unavailable")
|
||||||
|
end
|
||||||
|
|
||||||
contacts =
|
contacts =
|
||||||
Contact
|
Contact
|
||||||
|> where([c], not is_nil(c.pos1) or not is_nil(c.grid1))
|
|> where([c], not is_nil(c.pos1) or not is_nil(c.grid1))
|
||||||
|
|
@ -64,6 +79,29 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Flip contacts that have been stuck in :queued for longer than
|
||||||
|
# @stale_queued_cutoff_seconds to :unavailable. One Repo.update_all per type,
|
||||||
|
# scoped to only the types passed in args so a partial cron (e.g. hrrr-only)
|
||||||
|
# doesn't touch other statuses. Fast: hits the (<field>, updated_at) combo
|
||||||
|
# with a simple range scan.
|
||||||
|
defp reconcile_stale_queued_to_unavailable(types) do
|
||||||
|
cutoff =
|
||||||
|
DateTime.utc_now()
|
||||||
|
|> DateTime.add(-@stale_queued_cutoff_seconds, :second)
|
||||||
|
|> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
Enum.reduce(types, 0, fn type, acc ->
|
||||||
|
field = :"#{type}_status"
|
||||||
|
|
||||||
|
{n, _} =
|
||||||
|
Contact
|
||||||
|
|> where([c], field(c, ^field) == :queued and c.updated_at < ^cutoff)
|
||||||
|
|> Repo.update_all(set: [{field, :unavailable}])
|
||||||
|
|
||||||
|
acc + n
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
# Fast SQL reconciliation for contacts stuck in "queued" where data already exists.
|
# Fast SQL reconciliation for contacts stuck in "queued" where data already exists.
|
||||||
# Terrain profiles have a direct contact_id FK so we can reconcile in bulk.
|
# Terrain profiles have a direct contact_id FK so we can reconcile in bulk.
|
||||||
defp reconcile_stale_queued(types) do
|
defp reconcile_stale_queued(types) do
|
||||||
|
|
|
||||||
|
|
@ -114,4 +114,118 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
|
||||||
assert_receive {:enqueue_complete, 1}
|
assert_receive {:enqueue_complete, 1}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "reconcile stuck queued → unavailable" do
|
||||||
|
# A contact stuck at :queued for > 3 days is one the cron has already
|
||||||
|
# tried ~144 times without the underlying worker landing any data. At that
|
||||||
|
# point the data simply doesn't exist in the upstream archive (dead ASOS
|
||||||
|
# station, pre-2014 HRRR, out-of-grid IEMRE), so we stop retrying and flip
|
||||||
|
# it to :unavailable.
|
||||||
|
@stale_cutoff 3 * 86_400
|
||||||
|
|
||||||
|
defp stale_ts do
|
||||||
|
DateTime.utc_now()
|
||||||
|
|> DateTime.add(-(@stale_cutoff + 3600), :second)
|
||||||
|
|> DateTime.truncate(:second)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fresh_ts do
|
||||||
|
DateTime.utc_now()
|
||||||
|
|> DateTime.add(-3600, :second)
|
||||||
|
|> DateTime.truncate(:second)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the status field AND updated_at directly via update_all so Ecto
|
||||||
|
# timestamps can't overwrite what we put there. Also short-circuits the
|
||||||
|
# changeset, which ignores the *_status fields entirely.
|
||||||
|
defp force_status(contact, field, status, ts) do
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: [{field, status}, {:updated_at, ts}])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Complete the OTHER three statuses so the contact no longer matches the
|
||||||
|
# enqueue filter on them — only the one under test remains :queued. That
|
||||||
|
# keeps the main enqueue loop from running `mark_status!` on the contact
|
||||||
|
# during the same perform/1 call, which would bump updated_at back to now.
|
||||||
|
defp only_this_stuck(contact, field) do
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
other_fields = [:weather_status, :hrrr_status, :iemre_status, :terrain_status] -- [field]
|
||||||
|
sets = Enum.map(other_fields, &{&1, :complete})
|
||||||
|
|
||||||
|
Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: sets)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flips weather_status :queued → :unavailable when updated_at is older than the cutoff" do
|
||||||
|
contact = create_contact()
|
||||||
|
only_this_stuck(contact, :weather_status)
|
||||||
|
force_status(contact, :weather_status, :queued, stale_ts())
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
BackfillEnqueueWorker.perform(%Oban.Job{
|
||||||
|
args: %{"limit" => 10, "types" => @non_era5_types}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert %Contact{weather_status: :unavailable} = Repo.get!(Contact, contact.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flips hrrr_status :queued → :unavailable when updated_at is older than the cutoff" do
|
||||||
|
contact = create_contact()
|
||||||
|
only_this_stuck(contact, :hrrr_status)
|
||||||
|
force_status(contact, :hrrr_status, :queued, stale_ts())
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
BackfillEnqueueWorker.perform(%Oban.Job{
|
||||||
|
args: %{"limit" => 10, "types" => @non_era5_types}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert %Contact{hrrr_status: :unavailable} = Repo.get!(Contact, contact.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flips iemre_status :queued → :unavailable when updated_at is older than the cutoff" do
|
||||||
|
contact = create_contact()
|
||||||
|
only_this_stuck(contact, :iemre_status)
|
||||||
|
force_status(contact, :iemre_status, :queued, stale_ts())
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
BackfillEnqueueWorker.perform(%Oban.Job{
|
||||||
|
args: %{"limit" => 10, "types" => @non_era5_types}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert %Contact{iemre_status: :unavailable} = Repo.get!(Contact, contact.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does NOT flip fresh :queued contacts to :unavailable" do
|
||||||
|
contact = create_contact()
|
||||||
|
only_this_stuck(contact, :weather_status)
|
||||||
|
force_status(contact, :weather_status, :queued, fresh_ts())
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
BackfillEnqueueWorker.perform(%Oban.Job{
|
||||||
|
args: %{"limit" => 10, "types" => @non_era5_types}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Whatever the main enqueue loop does next (mark :queued, :complete,
|
||||||
|
# etc), reconcile_stale_queued_to_unavailable must NOT have fired: a
|
||||||
|
# contact fresher than the cutoff still has a chance at real data.
|
||||||
|
reloaded = Repo.get!(Contact, contact.id)
|
||||||
|
refute reloaded.weather_status == :unavailable
|
||||||
|
end
|
||||||
|
|
||||||
|
test "only reconciles the types passed in the args — doesn't touch unrelated statuses" do
|
||||||
|
# weather is stuck-queued-stale, but the cron is running with only hrrr
|
||||||
|
# in its types — weather_status should be left alone.
|
||||||
|
contact = create_contact()
|
||||||
|
only_this_stuck(contact, :weather_status)
|
||||||
|
force_status(contact, :weather_status, :queued, stale_ts())
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
BackfillEnqueueWorker.perform(%Oban.Job{
|
||||||
|
args: %{"limit" => 10, "types" => ["hrrr"]}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert %Contact{weather_status: :queued} = Repo.get!(Contact, contact.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue