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).
238 lines
7.8 KiB
Elixir
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
|