prop/lib/microwaveprop/workers/era5_submit_worker.ex
Graham McIntire 14a2284321
Handle CDS "rejected" status + widen submit cap headroom
CDS returns status="rejected" (distinct from "failed") when a submit
is rate-limited past the 150-job per-user cap. The poll worker's
interpret_status_body treated this as an unknown status → generic
retryable error → 10 retries → discarded. 102 jobs hit this path
overnight.

Fix:
  * Era5Client.check_status/1 now returns {:rejected, reason} (and
    treats "dismissed" the same way — that's the status a manually
    deleted job transitions to).
  * Era5PollWorker handles :rejected exactly like :not_found: drop the
    DB row, clean up the sibling CDS job, re-enqueue the submit, and
    discard the current Oban job so it doesn't burn attempts retrying
    a terminal state.
  * Widened Era5SubmitWorker cap headroom from 10 → 30 (effective
    ceiling 120 not 140). The race between concurrent workers all
    passing the count check simultaneously meant we were reaching 141
    in-flight against the 150 hard cap; 30 slots of headroom tolerates
    a ~15-worker race before clipping CDS's ceiling.

Refactored handle_row/1 into handle_row + handle_non_both_done with a
leg_outcome/1 tag helper so the cross-product of leg statuses collapses
to a single pair match (credo complexity limit).
2026-04-14 08:26:42 -05:00

238 lines
7.8 KiB
Elixir

defmodule Microwaveprop.Workers.Era5SubmitWorker do
@moduledoc """
First half of the split ERA5 backfill pipeline.
Submits the two CDS jobs for a month-tile (single-level + pressure-level)
in parallel, persists the returned CDS job IDs to `era5_cds_jobs`, and
enqueues an `Era5PollWorker` job scheduled a few minutes out to check for
completion.
The fast submit → enqueue-poll split means an Oban slot is held for ~1
second (the time to POST and write a row), not the 30-45 minutes CDS
takes to queue, assemble, and return a month-tile. A pod restart between
submit and poll does not lose the CDS work: the row in `era5_cds_jobs`
persists the job IDs and the poll worker picks up where we left off.
Idempotent: if the month-tile already has profiles (`era5_profiles`) or
an in-flight `era5_cds_jobs` row, no submit is made.
"""
use Oban.Worker,
queue: :era5_submit,
max_attempts: 5,
unique: [
period: :infinity,
states: [:available, :scheduled, :executing, :retryable],
keys: [:year, :month, :tile_lat, :tile_lon]
]
import Ecto.Query
alias Microwaveprop.Repo
alias Microwaveprop.Weather.Era5BatchClient
alias Microwaveprop.Weather.Era5CdsJob
alias Microwaveprop.Weather.Era5Client
alias Microwaveprop.Weather.Era5Profile
alias Microwaveprop.Workers.Era5PollWorker
require Logger
@poll_delay_seconds 5 * 60
# CDS enforces a server-side per-user cap of 150 in-flight jobs. Each
# successful submit adds 2 rows to era5_cds_jobs (single-level +
# pressure-level), so we stop submitting once the in-flight count is
# within `@cap_headroom` of the ceiling. Snoozing (instead of failing
# or sleeping) releases the Oban slot immediately and lets the poll
# workers drain in-flight jobs before we push more in.
#
# Headroom is deliberately generous (30 slots, ceiling of 120 effective)
# because the cap check races between concurrent workers: two workers
# that both observe count=119 will both submit, landing us at 121 after
# both finish. With 30 slots of headroom the cluster would have to race
# 15 workers wide to clip CDS's hard 150 ceiling. Observed:
# single-worker submits at count=141 → CDS reject storms, so we err on
# the side of staying well clear.
@cds_ceiling 150
@cap_headroom 30
@snooze_seconds 5 * 60
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
# The submit itself is cheap; retry fast so transient CDS/HTTP blips
# don't stall the tile for an hour waiting on exponential backoff.
min(30 * Integer.pow(2, attempt - 1), _one_hour = 3_600)
end
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
year = fetch_int!(args, "year")
month = fetch_int!(args, "month")
tile_lat = fetch_int!(args, "tile_lat")
tile_lon = fetch_int!(args, "tile_lon")
cond do
month_tile_cached?(year, month, tile_lat, tile_lon) ->
Logger.info("Era5Submit: #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon} already cached — skip")
{:ok, :cached}
existing = find_in_flight(year, month, tile_lat, tile_lon) ->
Logger.info(
"Era5Submit: #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon} already submitted (cds_job_id=#{existing.id}) — skip"
)
{:ok, :already_submitted}
cds_queue_full?() ->
in_flight = Repo.aggregate(Era5CdsJob, :count, :id)
Logger.info(
"Era5Submit: CDS in-flight cap reached (#{in_flight}/#{@cds_ceiling}) — snoozing #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon}"
)
{:snooze, @snooze_seconds}
true ->
submit_and_persist(year, month, tile_lat, tile_lon)
end
end
defp cds_queue_full? do
Repo.aggregate(Era5CdsJob, :count, :id) + 2 > @cds_ceiling - @cap_headroom
end
defp submit_and_persist(year, month, tile_lat, tile_lon) do
days = Calendar.ISO.days_in_month(year, month)
area = tile_area(tile_lat, tile_lon)
# Run both submits in parallel — they're independent HTTP POSTs.
single_task =
Task.async(fn ->
Era5Client.submit_job("reanalysis-era5-single-levels", single_request(year, month, days, area))
end)
pressure_task =
Task.async(fn ->
Era5Client.submit_job("reanalysis-era5-pressure-levels", pressure_request(year, month, days, area))
end)
try do
with {:ok, single_id} <- Task.await(single_task, :infinity),
{:ok, pressure_id} <- Task.await(pressure_task, :infinity),
{:ok, row} <-
insert_cds_job_row(year, month, tile_lat, tile_lon, single_id, pressure_id) do
enqueue_poll(row)
Logger.info(
"Era5Submit: submitted #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon} (single=#{single_id}, pressure=#{pressure_id})"
)
{:ok, row}
end
after
Task.shutdown(single_task, :brutal_kill)
Task.shutdown(pressure_task, :brutal_kill)
end
end
defp insert_cds_job_row(year, month, tile_lat, tile_lon, single_id, pressure_id) do
%Era5CdsJob{}
|> Era5CdsJob.changeset(%{
year: year,
month: month,
tile_lat: tile_lat,
tile_lon: tile_lon,
single_job_id: single_id,
pressure_job_id: pressure_id,
submitted_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.insert()
|> case do
{:ok, row} -> {:ok, row}
{:error, changeset} -> {:error, "era5_cds_jobs insert failed: #{inspect(changeset.errors)}"}
end
end
defp enqueue_poll(%Era5CdsJob{id: id}) do
%{"era5_cds_job_id" => id}
|> Era5PollWorker.new(schedule_in: @poll_delay_seconds)
|> Oban.insert()
end
defp find_in_flight(year, month, tile_lat, tile_lon) do
Repo.one(
from j in Era5CdsJob,
where:
j.year == ^year and j.month == ^month and j.tile_lat == ^tile_lat and
j.tile_lon == ^tile_lon,
limit: 1
)
end
defp month_tile_cached?(year, month, tile_lat, tile_lon) do
{first_dt, last_dt} = month_bounds(year, month)
tile_degrees = Era5BatchClient.tile_degrees()
Era5Profile
|> where(
[p],
p.valid_time >= ^first_dt and p.valid_time < ^last_dt and
p.lat >= ^(tile_lat * 1.0) and p.lat <= ^(tile_lat * 1.0 + tile_degrees) and
p.lon >= ^(tile_lon * 1.0) and p.lon <= ^(tile_lon * 1.0 + tile_degrees)
)
|> Repo.exists?()
end
defp month_bounds(year, month) do
{:ok, first} = Date.new(year, month, 1)
{:ok, first_dt} = DateTime.new(first, ~T[00:00:00], "Etc/UTC")
days = Calendar.ISO.days_in_month(year, month)
last_dt = DateTime.add(first_dt, days * 86_400, :second)
{first_dt, last_dt}
end
defp tile_area(tile_lat, tile_lon) do
tile_degrees = Era5BatchClient.tile_degrees()
Enum.map(
[tile_lat + tile_degrees, tile_lon, tile_lat, tile_lon + tile_degrees],
&(&1 * 1.0)
)
end
defp single_request(year, month, days, area) do
%{
"product_type" => ["reanalysis"],
"variable" => Era5Client.single_level_vars(),
"year" => [Integer.to_string(year)],
"month" => [pad(month)],
"day" => Enum.map(1..days, &pad/1),
"time" => Enum.map(0..23, &"#{pad(&1)}:00"),
"area" => area,
"data_format" => "grib"
}
end
defp pressure_request(year, month, days, area) do
%{
"product_type" => ["reanalysis"],
"variable" => Era5Client.pressure_level_vars(),
"pressure_level" => Era5Client.pressure_levels(),
"year" => [Integer.to_string(year)],
"month" => [pad(month)],
"day" => Enum.map(1..days, &pad/1),
"time" => Enum.map(0..23, &"#{pad(&1)}:00"),
"area" => area,
"data_format" => "grib"
}
end
defp pad(n), do: String.pad_leading(Integer.to_string(n), 2, "0")
defp fetch_int!(args, key) do
case Map.fetch!(args, key) do
v when is_integer(v) -> v
v when is_binary(v) -> String.to_integer(v)
end
end
end