prop/lib/microwaveprop/workers/era5_submit_worker.ex
Graham McIntire 1ec10bec1f
Split ERA5 backfill into submit/poll workers with persistent CDS state
The single Era5MonthBatchWorker pinned an Oban slot for the full 30-45
min a CDS month-tile takes to assemble, and lost the work entirely on a
rolling deploy because the in-flight Task died with the pod. This splits
the flow into two tiny workers and persists the CDS job IDs so deploys
survive.

New pipeline:

1. Era5SubmitWorker (:era5_submit queue) — POSTs both CDS requests in
   parallel, writes an `era5_cds_jobs` row with the returned job IDs,
   and enqueues an Era5PollWorker scheduled +5 min. ~1s of real work.
   Short-circuits when the month-tile is already cached or when a row
   already exists (in-flight from a previous attempt).

2. Era5PollWorker (:era5_poll queue) — reads the row, calls
   Era5Client.check_status/1 for both CDS job IDs, and:
     - returns {:snooze, 300} if either job is still running (Oban
       re-schedules without counting an attempt and releases the slot
       immediately — a pod can keep dozens of tile-months in flight
       without pinning workers)
     - streams both GRIB files to disk via Req into: File.stream!,
       decodes via Wgrib2.extract_grid_messages_from_file, bulk-inserts
       via Era5BatchClient.decode_and_insert/6, deletes the row, and
       DELETEs both completed jobs from CDS to free server-side quota
     - if either leg CDS-reports failed, deletes the row + both CDS
       jobs and returns {:error, reason}

Era5Client gains four testable building blocks:
  submit_job/2               (bare POST → {:ok, job_id})
  check_status/1             (GET → :running | {:done, src} | {:failed, reason})
  download_source_to_file/3  (streams {:url, href} or writes {:body, bin})
  delete_job/1               (DELETE /jobs/:id, treats 200/202/204/404 as :ok)

All Req calls now route through `era5_req_options` so tests can stub
CDS responses via Req.Test.stub(Era5Client, fn).

Era5MonthBatchWorker is retained as a thin forwarder to Era5SubmitWorker
so any jobs already in the :era5_batch queue on prod pods drain cleanly
on the next rolling deploy. Safe to delete in a follow-up.

Adds era5_cds_jobs table with a unique index on
(year, month, tile_lat, tile_lon) so duplicate submits collapse.

New queue config in runtime.exs:
  era5_submit: local_limit 4, rate_limit 30/hour (burst protection)
  era5_poll:   local_limit 20 (polls are cheap GETs)
  era5_batch:  kept at 1 for legacy job drain, delete next cycle
2026-04-13 16:26:26 -05:00

207 lines
6.5 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
@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}
true ->
submit_and_persist(year, month, tile_lat, tile_lon)
end
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