From fac604b2892f05a98efa55ebec81bc7ca1d6a647 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 14 Apr 2026 12:06:39 -0500 Subject: [PATCH] Fix ERA5 cap math + stuck threshold, split chip label onto two lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Era5SubmitWorker was counting era5_cds_jobs rows 1:1 against the CDS 150-job ceiling, but each row holds TWO CDS job IDs (single-level + pressure-level). Prod hit 74 rows = 148 in-flight jobs and got stuck in a reject→resubmit spiral. The cap guard now multiplies row count by 2 to match what CDS sees. Era5PollWorker @stuck_after_seconds goes from 4h to 18h. CDS single-level routinely sits at 'accepted' for 12+h under load while pressure finishes in ~90 min; the 4h threshold was firing on normal slow runs and feeding the same spiral. PipelineStatus now returns `label` + `detail` separately so the map chip can render "Updating propagation" on one line and the sub-detail ("HRRR run" / "ASOS nudge" / "+3h" / "now") on a second line below it. running_label/1 is renamed to running_detail/1 and returns just the sub-part. --- .../propagation/pipeline_status.ex | 29 ++++++----- lib/microwaveprop/workers/era5_poll_worker.ex | 20 ++++---- .../workers/era5_submit_worker.ex | 33 ++++++------ lib/microwaveprop_web/live/map_live.ex | 29 +++++++---- .../propagation/pipeline_status_test.exs | 26 +++++----- .../workers/era5_poll_worker_test.exs | 32 ++++++++++-- .../workers/era5_submit_worker_test.exs | 50 ++++++++++++++++--- 7 files changed, 150 insertions(+), 69 deletions(-) diff --git a/lib/microwaveprop/propagation/pipeline_status.ex b/lib/microwaveprop/propagation/pipeline_status.ex index cffe5751..7fe18da5 100644 --- a/lib/microwaveprop/propagation/pipeline_status.ex +++ b/lib/microwaveprop/propagation/pipeline_status.ex @@ -33,22 +33,22 @@ defmodule Microwaveprop.Propagation.PipelineStatus do @type t :: %{ state: state(), label: String.t(), + detail: String.t() | nil, last_update_at: DateTime.t() | nil } @doc """ Format a forecast-progress payload (as broadcast by - `PropagationGridWorker`) into a short chip label for the map. + `PropagationGridWorker`) into a short detail line for the running + chip ("now", "+3h", …). Returns `nil` when the payload has no `forecast_hour` so callers can - fall back to the base running label from `current/0`. + fall back to the base detail (e.g. "HRRR run") from `current/0`. """ - @spec running_label(map() | nil) :: String.t() | nil - def running_label(%{forecast_hour: 0}), do: "Updating propagation · now" - - def running_label(%{forecast_hour: fh}) when is_integer(fh) and fh > 0, do: "Updating propagation · +#{fh}h" - - def running_label(_), do: nil + @spec running_detail(map() | nil) :: String.t() | nil + def running_detail(%{forecast_hour: 0}), do: "now" + def running_detail(%{forecast_hour: fh}) when is_integer(fh) and fh > 0, do: "+#{fh}h" + def running_detail(_), do: nil @spec current() :: t() def current do @@ -56,7 +56,8 @@ defmodule Microwaveprop.Propagation.PipelineStatus do worker when is_binary(worker) -> %{ state: :running, - label: base_running_label(worker), + label: "Updating propagation", + detail: base_running_detail(worker), last_update_at: latest_completed_at() } @@ -90,7 +91,7 @@ defmodule Microwaveprop.Propagation.PipelineStatus do end defp build_idle_or_stale(nil) do - %{state: :unknown, label: "Propagation status unknown", last_update_at: nil} + %{state: :unknown, label: "Propagation status unknown", detail: nil, last_update_at: nil} end defp build_idle_or_stale(%DateTime{} = last) do @@ -100,20 +101,22 @@ defmodule Microwaveprop.Propagation.PipelineStatus do %{ state: :stale, label: "Propagation data stale · last update #{format_age(age_minutes)} ago", + detail: nil, last_update_at: last } else %{ state: :idle, label: "Up to date · #{format_age(age_minutes)} ago", + detail: nil, last_update_at: last } end end - defp base_running_label(@grid_worker), do: "Updating propagation (HRRR run)" - defp base_running_label(@asos_worker), do: "Updating propagation (ASOS nudge)" - defp base_running_label(_), do: "Updating propagation" + defp base_running_detail(@grid_worker), do: "HRRR run" + defp base_running_detail(@asos_worker), do: "ASOS nudge" + defp base_running_detail(_), do: nil defp format_age(0), do: "just now" defp format_age(1), do: "1m" diff --git a/lib/microwaveprop/workers/era5_poll_worker.ex b/lib/microwaveprop/workers/era5_poll_worker.ex index 5edf6c17..fcca9246 100644 --- a/lib/microwaveprop/workers/era5_poll_worker.ex +++ b/lib/microwaveprop/workers/era5_poll_worker.ex @@ -33,14 +33,16 @@ defmodule Microwaveprop.Workers.Era5PollWorker do @snooze_seconds 5 * 60 # A CDS job that's been in 'accepted' state for more than this is stuck - # in the server's queue and almost certainly won't run. (Observed in - # prod: pressure-level completes in ~90 min while single-level sits at - # 'accepted' for 16+ hours.) Once a row ages past this threshold we - # abandon the in-flight jobs, delete them from CDS, and re-submit the - # tile-month. 4 hours is ~8× the normal completion time — well above - # normal queue variance but tight enough that stuck jobs self-heal - # within a working session. - @stuck_after_seconds 4 * 3600 + # in the server's queue and almost certainly won't run. Observed in + # prod: pressure-level completes in ~90 min, but single-level + # routinely sits at 'accepted' for 12+ hours under CDS load. The + # previous 4h threshold was firing on normal slow runs, forcing a + # resubmit which then got rejected by the per-user cap — an infinite + # reject→resubmit spiral that burned CDS quota without producing any + # decoded data. 18h is comfortably above the worst observed queue + # times while still self-healing within a day. + @stuck_after_seconds 18 * 3600 + @stuck_after_hours div(@stuck_after_seconds, 3600) @impl Oban.Worker def perform(%Oban.Job{args: %{"era5_cds_job_id" => id}}) do @@ -64,7 +66,7 @@ defmodule Microwaveprop.Workers.Era5PollWorker do _other -> if stuck?(row) do - resubmit_vanished(row, "both", "stuck in CDS queue > 4h") + resubmit_vanished(row, "both", "stuck in CDS queue > #{@stuck_after_hours}h") else handle_non_both_done(row, single, pressure) end diff --git a/lib/microwaveprop/workers/era5_submit_worker.ex b/lib/microwaveprop/workers/era5_submit_worker.ex index a79ac12d..0e75e720 100644 --- a/lib/microwaveprop/workers/era5_submit_worker.ex +++ b/lib/microwaveprop/workers/era5_submit_worker.ex @@ -39,19 +39,20 @@ defmodule Microwaveprop.Workers.Era5SubmitWorker do @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. + # tile-month submit lives in ONE era5_cds_jobs row but carries TWO CDS + # job IDs (single-level + pressure-level), so the real in-flight count + # CDS sees is 2 × row_count. The cap guard multiplies accordingly. + # 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. + # Headroom is deliberately generous (30 job slots, effective ceiling + # 120 jobs = 60 rows) because the cap check races between concurrent + # workers: two workers that both observe row_count=59 can both submit, + # landing us at row_count=61 (122 jobs) 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 in prod: the old 1× math was + # allowing 74 rows (148 jobs) which triggered CDS reject storms. @cds_ceiling 150 @cap_headroom 30 @snooze_seconds 5 * 60 @@ -84,10 +85,11 @@ defmodule Microwaveprop.Workers.Era5SubmitWorker do {:ok, :already_submitted} cds_queue_full?() -> - in_flight = Repo.aggregate(Era5CdsJob, :count, :id) + row_count = Repo.aggregate(Era5CdsJob, :count, :id) + in_flight_jobs = row_count * 2 Logger.info( - "Era5Submit: CDS in-flight cap reached (#{in_flight}/#{@cds_ceiling}) — snoozing #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon}" + "Era5Submit: CDS in-flight cap reached (#{in_flight_jobs}/#{@cds_ceiling} jobs across #{row_count} rows) — snoozing #{year}-#{pad(month)} tile #{tile_lat},#{tile_lon}" ) {:snooze, @snooze_seconds} @@ -98,7 +100,8 @@ defmodule Microwaveprop.Workers.Era5SubmitWorker do end defp cds_queue_full? do - Repo.aggregate(Era5CdsJob, :count, :id) + 2 > @cds_ceiling - @cap_headroom + in_flight_jobs_after_submit = Repo.aggregate(Era5CdsJob, :count, :id) * 2 + 2 + in_flight_jobs_after_submit > @cds_ceiling - @cap_headroom end defp submit_and_persist(year, month, tile_lat, tile_lon) do diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 20b8ce62..7580633a 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -384,14 +384,20 @@ defmodule MicrowavepropWeb.MapLive do attr :progress, :any, default: nil defp pipeline_status_chip(assigns) do - assigns = assign(assigns, :display_label, chip_display_label(assigns.status, assigns.progress)) + {main, detail} = chip_display_parts(assigns.status, assigns.progress) + + assigns = + assigns + |> assign(:display_main, main) + |> assign(:display_detail, detail) + |> assign(:display_title, if(detail, do: "#{main} — #{detail}", else: main)) ~H"""
<%= case @status.state do %> <% :running -> %> @@ -399,26 +405,31 @@ defmodule MicrowavepropWeb.MapLive do name="hero-arrow-path" class="size-3.5 shrink-0 mt-0.5 text-info motion-safe:animate-spin" /> - {@display_label} + + {@display_main} + <%= if @display_detail do %> + {@display_detail} + <% end %> + <% :idle -> %> - {@display_label} + {@display_main} <% :stale -> %> - {@display_label} + {@display_main} <% :unknown -> %> - {@display_label} + {@display_main} <% end %>
""" end - defp chip_display_label(%{state: :running} = status, progress) do - PipelineStatus.running_label(progress) || status.label + defp chip_display_parts(%{state: :running} = status, progress) do + {status.label, PipelineStatus.running_detail(progress) || status.detail} end - defp chip_display_label(status, _progress), do: status.label + defp chip_display_parts(status, _progress), do: {status.label, nil} @impl true def render(assigns) do diff --git a/test/microwaveprop/propagation/pipeline_status_test.exs b/test/microwaveprop/propagation/pipeline_status_test.exs index 29b9979c..7a0ee22a 100644 --- a/test/microwaveprop/propagation/pipeline_status_test.exs +++ b/test/microwaveprop/propagation/pipeline_status_test.exs @@ -41,7 +41,7 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do assert is_binary(status.label) end - test "returns :running with grid worker label when PropagationGridWorker is executing" do + test "returns :running with grid worker detail when PropagationGridWorker is executing" do insert_oban_job(%{ state: "executing", worker: @grid_worker, @@ -51,10 +51,11 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do status = PipelineStatus.current() assert status.state == :running - assert status.label =~ "HRRR" + assert status.label == "Updating propagation" + assert status.detail =~ "HRRR" end - test "returns :running with ASOS label when AsosAdjustmentWorker is executing" do + test "returns :running with ASOS detail when AsosAdjustmentWorker is executing" do insert_oban_job(%{ state: "executing", worker: @asos_worker, @@ -64,7 +65,8 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do status = PipelineStatus.current() assert status.state == :running - assert status.label =~ "ASOS" + assert status.label == "Updating propagation" + assert status.detail =~ "ASOS" end test "returns :idle with Up to date label when last grid run completed recently" do @@ -141,23 +143,19 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do end end - describe "running_label/1" do + describe "running_detail/1" do test "forecast hour 0 renders as 'now'" do - assert PipelineStatus.running_label(%{forecast_hour: 0}) == - "Updating propagation · now" + assert PipelineStatus.running_detail(%{forecast_hour: 0}) == "now" end test "positive forecast hours render as +Nh" do - assert PipelineStatus.running_label(%{forecast_hour: 1}) == - "Updating propagation · +1h" - - assert PipelineStatus.running_label(%{forecast_hour: 18}) == - "Updating propagation · +18h" + assert PipelineStatus.running_detail(%{forecast_hour: 1}) == "+1h" + assert PipelineStatus.running_detail(%{forecast_hour: 18}) == "+18h" end test "returns nil when no forecast hour is provided" do - assert PipelineStatus.running_label(nil) == nil - assert PipelineStatus.running_label(%{}) == nil + assert PipelineStatus.running_detail(nil) == nil + assert PipelineStatus.running_detail(%{}) == nil end end end diff --git a/test/microwaveprop/workers/era5_poll_worker_test.exs b/test/microwaveprop/workers/era5_poll_worker_test.exs index e243859e..716211b3 100644 --- a/test/microwaveprop/workers/era5_poll_worker_test.exs +++ b/test/microwaveprop/workers/era5_poll_worker_test.exs @@ -14,7 +14,7 @@ defmodule Microwaveprop.Workers.Era5PollWorkerTest do end defp insert_cds_job(overrides \\ %{}) do - # Default to "just now" so the stuck-after-4h guard doesn't misfire + # Default to "just now" so the stuck-after guard doesn't misfire # and re-submit in tests that want to exercise status-specific # branches. Tests that care about the stuck path pass a stale # submitted_at explicitly. @@ -166,10 +166,15 @@ defmodule Microwaveprop.Workers.Era5PollWorkerTest do # on era5_cds_jobs, so once the row ages past @stuck_after, poll # treats it as terminal and re-submits — same path as :not_found / # :rejected. - test "re-submits when submitted_at > 4h ago and at least one leg is still running" do + # + # Threshold is 18h (see @stuck_after_seconds in Era5PollWorker): CDS + # single-level routinely takes 12+ hours under load while pressure + # finishes in ~90 minutes, and the 4h threshold was firing on normal + # slow runs, causing re-submit storms that hit the per-user cap. + test "re-submits when submitted_at is older than 18h and at least one leg is still running" do stale_submit = DateTime.utc_now() - |> DateTime.add(-5 * 3600, :second) + |> DateTime.add(-19 * 3600, :second) |> DateTime.truncate(:second) row = insert_cds_job(%{submitted_at: stale_submit}) @@ -201,6 +206,27 @@ defmodule Microwaveprop.Workers.Era5PollWorkerTest do ) end + test "does NOT re-submit a 10h-old row that's still running (under the 18h threshold)" do + # Observed in prod: CDS single-level can sit at 'accepted' for 12+ + # hours while pressure completes in ~90 min. Anything under 18h + # should keep snoozing, not trigger a re-submit storm. + slowish_submit = + DateTime.utc_now() + |> DateTime.add(-10 * 3600, :second) + |> DateTime.truncate(:second) + + row = insert_cds_job(%{submitted_at: slowish_submit}) + + Req.Test.stub(Era5Client, fn conn -> + Req.Test.json(conn, %{"status" => "running"}) + end) + + assert {:snooze, _} = + Era5PollWorker.perform(%Oban.Job{args: %{"era5_cds_job_id" => row.id}}) + + assert Repo.get(Era5CdsJob, row.id) + end + test "does NOT re-submit when submitted_at is recent even if running" do # A 30-minute-old row with both legs running is normal — snooze. fresh_submit = diff --git a/test/microwaveprop/workers/era5_submit_worker_test.exs b/test/microwaveprop/workers/era5_submit_worker_test.exs index ec997e61..d3cdc214 100644 --- a/test/microwaveprop/workers/era5_submit_worker_test.exs +++ b/test/microwaveprop/workers/era5_submit_worker_test.exs @@ -126,12 +126,13 @@ defmodule Microwaveprop.Workers.Era5SubmitWorkerTest do describe "CDS in-flight cap" do # CDS rejects with "Number of queued requests is limited to 150" when - # a user has too many pending jobs. Each submit adds 2 rows to - # era5_cds_jobs (single-level + pressure-level), so we snooze when - # the in-flight count is close to the ceiling. The exact threshold - # (headroom) is tuned in the worker; the test just seeds well above - # the effective threshold (140 rows) so whatever the worker picks, - # it should trip the snooze. + # a user has too many pending jobs. Each tile-month lives in ONE + # era5_cds_jobs row but carries TWO CDS job IDs (single-level + + # pressure-level), so the real in-flight count is 2 × row_count. The + # cap guard multiplies accordingly. The exact threshold (headroom) is + # tuned in the worker; the tests below exercise both the obvious + # "way above the cap" path and a row count that should only trip the + # snooze if the 2× math is in place. @cds_ceiling 150 test "snoozes when era5_cds_jobs count is at or above the snooze threshold" do @@ -171,6 +172,43 @@ defmodule Microwaveprop.Workers.Era5SubmitWorkerTest do ) end + test "snoozes when row count × 2 crosses the cap (each row = two CDS jobs)" do + # 65 rows = 130 in-flight CDS jobs — above the effective snooze + # threshold even though row count alone is nowhere near 150. This + # is the scenario that was escaping the old guard and driving the + # per-user-cap reject storms in prod. + rows = + for i <- 1..65 do + %{ + id: Ecto.UUID.generate(), + year: 2014, + month: rem(i - 1, 12) + 1, + tile_lat: 32, + tile_lon: -98 - i, + single_job_id: "x2-single-#{i}", + pressure_job_id: "x2-pressure-#{i}", + submitted_at: DateTime.truncate(DateTime.utc_now(), :second), + inserted_at: DateTime.truncate(DateTime.utc_now(), :second), + updated_at: DateTime.truncate(DateTime.utc_now(), :second), + poll_count: 0 + } + end + + Repo.insert_all(Era5CdsJob, rows) + + Req.Test.stub(Era5Client, fn _ -> raise "CDS should not be contacted when capped" end) + + Oban.Testing.with_testing_mode(:manual, fn -> + assert {:snooze, seconds} = Era5SubmitWorker.perform(%Oban.Job{args: @default_args}) + assert is_integer(seconds) and seconds > 0 + end) + + refute Repo.one( + from j in Era5CdsJob, + where: j.year == 2014 and j.month == 3 and j.tile_lat == 32 and j.tile_lon == -98 + ) + end + test "submits normally when in-flight count is well below the ceiling" do # 10 in-flight — comfortably under the cap. rows =