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
42 lines
1.2 KiB
Elixir
42 lines
1.2 KiB
Elixir
defmodule Microwaveprop.Workers.Era5MonthBatchWorker do
|
|
@moduledoc """
|
|
Legacy entry point for the ERA5 month-tile backfill. **Forwards** to
|
|
`Era5SubmitWorker` in the new split-worker pipeline — kept in place so
|
|
already-enqueued callers (`Era5FetchWorker`, `mix era5.backfill`, any
|
|
in-flight Oban rows) don't need to be changed on the same deploy.
|
|
|
|
On the next deploy cycle, callers will be updated to enqueue
|
|
`Era5SubmitWorker` directly and this module can be removed.
|
|
"""
|
|
use Oban.Worker,
|
|
queue: :era5_submit,
|
|
max_attempts: 5,
|
|
unique: [
|
|
period: :infinity,
|
|
states: [:available, :scheduled, :executing, :retryable],
|
|
keys: [:year, :month, :tile_lat, :tile_lon]
|
|
]
|
|
|
|
alias Microwaveprop.Workers.Era5SubmitWorker
|
|
|
|
require Logger
|
|
|
|
@impl Oban.Worker
|
|
def backoff(%Oban.Job{attempt: attempt}) do
|
|
# Forwarding is cheap; retry fast on transient failure.
|
|
min(30 * Integer.pow(2, attempt - 1), 3_600)
|
|
end
|
|
|
|
@impl Oban.Worker
|
|
def perform(%Oban.Job{args: args}) do
|
|
Logger.debug("Era5MonthBatchWorker forwarding to Era5SubmitWorker: #{inspect(args)}")
|
|
|
|
args
|
|
|> Era5SubmitWorker.new()
|
|
|> Oban.insert()
|
|
|> case do
|
|
{:ok, _job} -> :ok
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
end
|