prop/test/microwaveprop/workers/era5_poll_worker_test.exs
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

96 lines
3 KiB
Elixir

defmodule Microwaveprop.Workers.Era5PollWorkerTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Weather.Era5CdsJob
alias Microwaveprop.Weather.Era5Client
alias Microwaveprop.Workers.Era5PollWorker
setup do
System.put_env("ERA5_CDS_API_KEY", "test-key")
on_exit(fn -> System.delete_env("ERA5_CDS_API_KEY") end)
:ok
end
defp insert_cds_job(overrides \\ %{}) do
attrs =
Map.merge(
%{
year: 2014,
month: 3,
tile_lat: 32,
tile_lon: -98,
single_job_id: "cds-single-123",
pressure_job_id: "cds-pressure-456",
submitted_at: ~U[2026-04-13 21:00:00Z]
},
overrides
)
{:ok, row} = %Era5CdsJob{} |> Era5CdsJob.changeset(attrs) |> Repo.insert()
row
end
describe "perform/1 — row lifecycle" do
test "returns :ok and does nothing when the era5_cds_jobs row no longer exists" do
# Row was already processed/deleted by a previous poll run; snooze is
# scheduled behind us in time; just no-op.
Req.Test.stub(Era5Client, fn _ -> raise "CDS should not be contacted" end)
missing_id = Ecto.UUID.generate()
assert :ok =
Era5PollWorker.perform(%Oban.Job{args: %{"era5_cds_job_id" => missing_id}})
end
end
describe "perform/1 — still running" do
test "returns {:snooze, seconds} and bumps poll_count when either CDS job is still running" do
row = insert_cds_job()
Req.Test.stub(Era5Client, fn conn ->
assert conn.method == "GET"
Req.Test.json(conn, %{"status" => "running"})
end)
assert {:snooze, snooze_s} =
Era5PollWorker.perform(%Oban.Job{args: %{"era5_cds_job_id" => row.id}})
assert snooze_s > 0
reloaded = Repo.get!(Era5CdsJob, row.id)
assert reloaded.poll_count == 1
assert reloaded.last_polled_at
end
end
describe "perform/1 — failure" do
test "returns {:error, _}, deletes the row, and deletes both CDS jobs when a leg fails" do
row = insert_cds_job()
test_pid = self()
Req.Test.stub(Era5Client, fn conn ->
cond do
conn.method == "GET" and String.contains?(conn.request_path, "cds-single-123") ->
Req.Test.json(conn, %{"status" => "failed", "message" => "bad input"})
conn.method == "GET" and String.contains?(conn.request_path, "cds-pressure-456") ->
Req.Test.json(conn, %{"status" => "running"})
conn.method == "DELETE" ->
send(test_pid, {:delete, conn.request_path})
Plug.Conn.resp(conn, 204, "")
end
end)
assert {:error, reason} =
Era5PollWorker.perform(%Oban.Job{args: %{"era5_cds_job_id" => row.id}})
assert reason =~ "bad input"
refute Repo.get(Era5CdsJob, row.id)
# Both CDS jobs are deleted to free server-side quota.
assert_received {:delete, "/api/retrieve/v1/jobs/cds-single-123"}
assert_received {:delete, "/api/retrieve/v1/jobs/cds-pressure-456"}
end
end
end