defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do use Microwaveprop.DataCase, async: true import Ecto.Query alias Microwaveprop.Propagation.GridTaskEnqueuer alias Microwaveprop.Repo test "seeds one row per fh=1..18" do run_time = ~U[2026-04-19 15:00:00Z] assert {:ok, 18} = GridTaskEnqueuer.seed(run_time) rows = Repo.all( from(g in "grid_tasks", where: g.run_time == ^run_time, select: %{forecast_hour: g.forecast_hour, status: g.status, valid_time: g.valid_time} ) ) assert length(rows) == 18 assert rows |> Enum.map(& &1.forecast_hour) |> Enum.sort() == Enum.to_list(1..18) assert Enum.all?(rows, &(&1.status == "queued")) # Raw schemaless selects return NaiveDateTime; compare against the # equivalent naive form. expected_naive = DateTime.to_naive(run_time) Enum.each(rows, fn r -> expected = NaiveDateTime.add(expected_naive, r.forecast_hour * 3600, :second) assert NaiveDateTime.compare(r.valid_time, expected) == :eq end) end test "is idempotent: reseeding the same run_time inserts nothing" do run_time = ~U[2026-04-19 16:00:00Z] assert {:ok, 18} = GridTaskEnqueuer.seed(run_time) assert {:ok, 0} = GridTaskEnqueuer.seed(run_time) count = Repo.one(from(g in "grid_tasks", where: g.run_time == ^run_time, select: count())) assert count == 18 end test "never emits a row for fh=0 — Elixir keeps that chain step" do run_time = ~U[2026-04-19 17:00:00Z] assert {:ok, 18} = GridTaskEnqueuer.seed(run_time) f0_count = Repo.one(from(g in "grid_tasks", where: g.run_time == ^run_time and g.forecast_hour == 0, select: count())) assert f0_count == 0 end describe "source-aware seeding" do test "seed/2 defaults to source='hrrr'" do run_time = ~U[2026-04-29 12:00:00Z] assert {:ok, 18} = GridTaskEnqueuer.seed(run_time) sources = Repo.all(from(g in "grid_tasks", where: g.run_time == ^run_time, select: g.source)) assert Enum.uniq(sources) == ["hrrr"] end test "seed_with_analysis/2 with source='hrdps' tags every row" do run_time = ~U[2026-04-29 12:00:00Z] assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps") sources = Repo.all(from(g in "grid_tasks", where: g.run_time == ^run_time, select: g.source)) assert length(sources) == 19 assert Enum.uniq(sources) == ["hrdps"] end test "HRRR and HRDPS chains for the same run_time coexist (4-column unique key)" do run_time = ~U[2026-04-29 18:00:00Z] assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrrr") assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps") total = Repo.one(from(g in "grid_tasks", where: g.run_time == ^run_time, select: count())) assert total == 38 end test "reseeding the same run_time + source is idempotent" do run_time = ~U[2026-04-29 06:00:00Z] assert {:ok, 19} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps") assert {:ok, 0} = GridTaskEnqueuer.seed_with_analysis(run_time, source: "hrdps") end end describe "reclaim_stale_running/1" do test "flips stale-running rows back to queued and clears claimed_at" do now = DateTime.truncate(DateTime.utc_now(), :second) stale = DateTime.add(now, -30 * 60, :second) insert_task(%{ run_time: ~U[2026-04-19 20:00:00Z], forecast_hour: 5, valid_time: ~U[2026-04-20 01:00:00Z], status: "running", attempt: 1, claimed_at: stale }) assert %{requeued: 1, failed: 0} = GridTaskEnqueuer.reclaim_stale_running() row = Repo.one!( from(t in "grid_tasks", where: t.run_time == ^~U[2026-04-19 20:00:00Z] and t.forecast_hour == 5, select: %{status: t.status, claimed_at: t.claimed_at, attempt: t.attempt} ) ) assert row.status == "queued" assert is_nil(row.claimed_at) # attempt is NOT incremented — claim_next does that on re-claim. assert row.attempt == 1 end test "leaves recently-claimed running rows untouched" do now = DateTime.truncate(DateTime.utc_now(), :second) fresh = DateTime.add(now, -60, :second) insert_task(%{ run_time: ~U[2026-04-19 21:00:00Z], forecast_hour: 6, valid_time: ~U[2026-04-20 03:00:00Z], status: "running", attempt: 1, claimed_at: fresh }) assert %{requeued: 0, failed: 0} = GridTaskEnqueuer.reclaim_stale_running() row = Repo.one!( from(t in "grid_tasks", where: t.run_time == ^~U[2026-04-19 21:00:00Z] and t.forecast_hour == 6, select: %{status: t.status} ) ) assert row.status == "running" end test "flips stale rows past max attempts to failed" do now = DateTime.truncate(DateTime.utc_now(), :second) stale = DateTime.add(now, -30 * 60, :second) insert_task(%{ run_time: ~U[2026-04-19 22:00:00Z], forecast_hour: 7, valid_time: ~U[2026-04-20 05:00:00Z], status: "running", attempt: 5, claimed_at: stale }) assert %{requeued: 0, failed: 1} = GridTaskEnqueuer.reclaim_stale_running() row = Repo.one!( from(t in "grid_tasks", where: t.run_time == ^~U[2026-04-19 22:00:00Z] and t.forecast_hour == 7, select: %{status: t.status, error: t.error, claimed_at: t.claimed_at} ) ) assert row.status == "failed" assert is_nil(row.claimed_at) assert row.error =~ "reclaim" end test "does not touch queued or done rows even if claimed_at is ancient" do now = DateTime.truncate(DateTime.utc_now(), :second) stale = DateTime.add(now, -30 * 60, :second) insert_task(%{ run_time: ~U[2026-04-19 23:00:00Z], forecast_hour: 8, valid_time: ~U[2026-04-20 07:00:00Z], status: "done", attempt: 1, claimed_at: stale, completed_at: stale }) assert %{requeued: 0, failed: 0} = GridTaskEnqueuer.reclaim_stale_running() end end describe "seed_current_hour/2" do test "seeds exactly one forecast row at the hour offset closest to now" do # run_time 5h before "now" → fh should be 5. now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) run_time = DateTime.add(now, -5 * 3600, :second) assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") rows = Repo.all( from(g in "grid_tasks", where: g.run_time == ^run_time and g.source == "hrdps", select: %{kind: g.kind, forecast_hour: g.forecast_hour, valid_time: g.valid_time} ) ) assert length(rows) == 1 [%{kind: kind, forecast_hour: fh, valid_time: vt}] = rows assert kind == "forecast" assert fh == 5 assert NaiveDateTime.compare(vt, DateTime.to_naive(now)) == :eq end test "is idempotent on the (run_time, fh, kind, source) key" do now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) run_time = DateTime.add(now, -3 * 3600, :second) assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") assert {:ok, 0} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") count = Repo.one( from(g in "grid_tasks", where: g.run_time == ^run_time and g.source == "hrdps", select: count() ) ) assert count == 1 end test "clamps fh to 1 when the cycle is in the future" do # run_time 1h *after* now → diff is -3600 → would naturally be 0 or # negative. Clamp pulls it to 1 so the F00Reserved branch in Rust # is never hit through this path. now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) run_time = DateTime.add(now, 3600, :second) assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") [%{forecast_hour: fh}] = Repo.all( from(g in "grid_tasks", where: g.run_time == ^run_time and g.source == "hrdps", select: %{forecast_hour: g.forecast_hour} ) ) assert fh == 1 end end defp insert_task(attrs) do now = DateTime.truncate(DateTime.utc_now(), :microsecond) row = Map.merge( %{ id: Ecto.UUID.bingenerate(), status: "queued", attempt: 0, kind: "forecast", claimed_at: nil, completed_at: nil, error: nil, inserted_at: now, updated_at: now }, attrs ) {1, _} = Repo.insert_all("grid_tasks", [row]) end end