Stop PropagationGridWorker crash-looping on forecast hours
warm_grid_cache_and_broadcast was re-SELECTing 92k hrrr_profiles rows with JSONB profile/duct_characteristics columns on every forecast hour. Row decoding exceeded the 15s default DB connection timeout, killing the worker before f01-f18 could run. Oban retried from f00 and got stuck in a loop: for the last several hours, no forecast hours were being written and even f00 was stale. Build the weather cache rows directly from the in-memory grid_data the worker already has (Weather.build_grid_cache_rows/2) and GridCache.broadcast_put directly. No DB round trip, no JSONB decode. Also widen the cron from hourly to every 3 hours so a full f00-f18 sweep (~95 min) can actually complete before the next run starts, and drop the redundant prune_old_scores() at worker start (PropagationPruneWorker already runs every 15 min). Add a 60s query timeout to load_weather_grid_from_db as defense-in-depth for the cold-cache fill path that still uses it.
This commit is contained in:
parent
c020d9c788
commit
2405c5f169
6 changed files with 228 additions and 13 deletions
|
|
@ -69,7 +69,7 @@ config :microwaveprop, Oban,
|
||||||
crontab: [
|
crontab: [
|
||||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||||
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
|
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
|
||||||
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
{"5 */3 * * *", Microwaveprop.Workers.PropagationGridWorker},
|
||||||
{"*/2 * * * *", Microwaveprop.Workers.MrmsFetchWorker},
|
{"*/2 * * * *", Microwaveprop.Workers.MrmsFetchWorker},
|
||||||
{"*/10 * * * *", Microwaveprop.Workers.AsosAdjustmentWorker},
|
{"*/10 * * * *", Microwaveprop.Workers.AsosAdjustmentWorker},
|
||||||
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker}
|
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker}
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ config :microwaveprop, Oban,
|
||||||
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
|
{Oban.Plugins.Lifeline, rescue_after: to_timeout(minute: 30)},
|
||||||
{Oban.Plugins.Cron,
|
{Oban.Plugins.Cron,
|
||||||
crontab: [
|
crontab: [
|
||||||
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
{"5 */3 * * *", Microwaveprop.Workers.PropagationGridWorker},
|
||||||
{"*/2 * * * *", Microwaveprop.Workers.MrmsFetchWorker},
|
{"*/2 * * * *", Microwaveprop.Workers.MrmsFetchWorker},
|
||||||
{"*/10 * * * *", Microwaveprop.Workers.AsosAdjustmentWorker},
|
{"*/10 * * * *", Microwaveprop.Workers.AsosAdjustmentWorker},
|
||||||
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker},
|
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker},
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,10 @@ if config_env() == :prod do
|
||||||
crontab: [
|
crontab: [
|
||||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||||
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
|
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
|
||||||
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
# Every 3 hours: one full run does f00–f18 (~5 min/hour × 19 ≈ 95 min).
|
||||||
|
# Hourly cron caused overlapping/failed runs because one full sweep
|
||||||
|
# exceeds a 60-minute window.
|
||||||
|
{"5 */3 * * *", Microwaveprop.Workers.PropagationGridWorker},
|
||||||
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker},
|
{"*/15 * * * *", Microwaveprop.Workers.PropagationPruneWorker},
|
||||||
{"*/30 * * * *", Microwaveprop.Workers.BackfillEnqueueWorker,
|
{"*/30 * * * *", Microwaveprop.Workers.BackfillEnqueueWorker,
|
||||||
args: %{"limit" => 500, "types" => ["hrrr", "weather", "terrain", "iemre"]}}
|
args: %{"limit" => 500, "types" => ["hrrr", "weather", "terrain", "iemre"]}}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ defmodule Microwaveprop.Weather do
|
||||||
alias Microwaveprop.Weather.RtmaObservation
|
alias Microwaveprop.Weather.RtmaObservation
|
||||||
alias Microwaveprop.Weather.SolarIndex
|
alias Microwaveprop.Weather.SolarIndex
|
||||||
alias Microwaveprop.Weather.Sounding
|
alias Microwaveprop.Weather.Sounding
|
||||||
|
alias Microwaveprop.Weather.SoundingParams
|
||||||
alias Microwaveprop.Weather.Station
|
alias Microwaveprop.Weather.Station
|
||||||
alias Microwaveprop.Weather.SurfaceObservation
|
alias Microwaveprop.Weather.SurfaceObservation
|
||||||
alias Microwaveprop.Weather.WeatherLayers
|
alias Microwaveprop.Weather.WeatherLayers
|
||||||
|
|
@ -481,15 +482,16 @@ defmodule Microwaveprop.Weather do
|
||||||
duct_characteristics: h.duct_characteristics
|
duct_characteristics: h.duct_characteristics
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|> Repo.all()
|
|> Repo.all(timeout: 60_000)
|
||||||
|> Enum.map(&derive_and_clean/1)
|
|> Enum.map(&derive_and_clean/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Eagerly populate the `GridCache` with the full CONUS weather grid for
|
Eagerly populate the `GridCache` with the full CONUS weather grid for
|
||||||
`valid_time` and broadcast it to every node in the cluster. Called from
|
`valid_time` and broadcast it to every node in the cluster. Used by the cold
|
||||||
`PropagationGridWorker` after each hourly HRRR upsert so connected clients
|
cache fill path (`kickoff_async_grid_fill/1`) — prefer
|
||||||
see zero-DB pan/zoom latency on the `/weather` map.
|
`build_grid_cache_rows/2` inside `PropagationGridWorker`, which already has
|
||||||
|
the in-memory grid data and avoids the ~20s JSONB round trip.
|
||||||
"""
|
"""
|
||||||
@spec warm_grid_cache_and_broadcast(DateTime.t()) :: :ok
|
@spec warm_grid_cache_and_broadcast(DateTime.t()) :: :ok
|
||||||
def warm_grid_cache_and_broadcast(valid_time) do
|
def warm_grid_cache_and_broadcast(valid_time) do
|
||||||
|
|
@ -498,6 +500,65 @@ defmodule Microwaveprop.Weather do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Build derived weather grid cache rows directly from the in-memory HRRR
|
||||||
|
`grid_data` that `PropagationGridWorker` already has after fetching a
|
||||||
|
forecast hour. Avoids the 15s+ DB round trip that `load_weather_grid_from_db/1`
|
||||||
|
pays to SELECT + JSONB-decode 92k hrrr_profiles rows — the source of the
|
||||||
|
crash loop that stopped forecast hours from being written.
|
||||||
|
|
||||||
|
`grid_data` is `%{{lat, lon} => profile_map}` as produced by
|
||||||
|
`HrrrClient.fetch_grid/3` (optionally enriched via native duct merge). The
|
||||||
|
output matches the shape of `load_weather_grid_from_db/1` after
|
||||||
|
`derive_and_clean/1`.
|
||||||
|
"""
|
||||||
|
@spec build_grid_cache_rows(%{{float(), float()} => map()}, DateTime.t()) :: [map()]
|
||||||
|
def build_grid_cache_rows(grid_data, valid_time) do
|
||||||
|
Enum.flat_map(grid_data, fn {{lat, lon}, profile} ->
|
||||||
|
build_grid_cache_row(lat, lon, profile, valid_time)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_grid_cache_row(lat, lon, profile, valid_time) do
|
||||||
|
temp_c = profile[:surface_temp_c]
|
||||||
|
|
||||||
|
if is_nil(temp_c) or temp_c < -80 or temp_c > 60 do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
sounding = derive_sounding(profile[:profile])
|
||||||
|
|
||||||
|
row = %{
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
valid_time: valid_time,
|
||||||
|
temperature: temp_c,
|
||||||
|
dewpoint_depression: depression(temp_c, profile[:surface_dewpoint_c]),
|
||||||
|
bl_height: profile[:hpbl_m],
|
||||||
|
pwat: profile[:pwat_mm],
|
||||||
|
refractivity_gradient: sounding[:min_refractivity_gradient],
|
||||||
|
ducting: sounding[:ducting_detected],
|
||||||
|
surface_pressure_mb: profile[:surface_pressure_mb],
|
||||||
|
surface_temp_c: temp_c,
|
||||||
|
surface_dewpoint_c: profile[:surface_dewpoint_c],
|
||||||
|
surface_refractivity: sounding[:surface_refractivity],
|
||||||
|
profile: profile[:profile] || [],
|
||||||
|
duct_characteristics: sounding[:duct_characteristics]
|
||||||
|
}
|
||||||
|
|
||||||
|
[derive_and_clean(row)]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp derive_sounding(profile) when is_list(profile) and length(profile) >= 3 do
|
||||||
|
SoundingParams.derive(profile) || %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp derive_sounding(_), do: %{}
|
||||||
|
|
||||||
|
defp depression(nil, _), do: nil
|
||||||
|
defp depression(_, nil), do: nil
|
||||||
|
defp depression(t, d), do: t - d
|
||||||
|
|
||||||
@spec weather_point_detail(float(), float(), DateTime.t()) :: map() | nil
|
@spec weather_point_detail(float(), float(), DateTime.t()) :: map() | nil
|
||||||
def weather_point_detail(lat, lon, valid_time) do
|
def weather_point_detail(lat, lon, valid_time) do
|
||||||
step = 0.125
|
step = 0.125
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||||||
alias Microwaveprop.Propagation.Grid
|
alias Microwaveprop.Propagation.Grid
|
||||||
alias Microwaveprop.Propagation.ScoreCache
|
alias Microwaveprop.Propagation.ScoreCache
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
|
alias Microwaveprop.Weather.GridCache
|
||||||
alias Microwaveprop.Weather.HrrrClient
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
alias Microwaveprop.Weather.HrrrNativeClient
|
alias Microwaveprop.Weather.HrrrNativeClient
|
||||||
alias Microwaveprop.Weather.SoundingParams
|
alias Microwaveprop.Weather.SoundingParams
|
||||||
|
|
@ -27,11 +28,6 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||||||
def perform(%Oban.Job{}) do
|
def perform(%Oban.Job{}) do
|
||||||
t_start = System.monotonic_time(:millisecond)
|
t_start = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
# Safety net: prune before compute so a row of failed runs doesn't let
|
|
||||||
# stale scores accumulate indefinitely. Pruning also runs on its own cron
|
|
||||||
# (`PropagationPruneWorker`) independent of this worker's success.
|
|
||||||
Propagation.prune_old_scores()
|
|
||||||
|
|
||||||
# HRRR takes ~45min to publish after the hour. Use 2 hours ago to ensure availability.
|
# HRRR takes ~45min to publish after the hour. Use 2 hours ago to ensure availability.
|
||||||
two_hours_ago = DateTime.add(DateTime.utc_now(), -2, :hour)
|
two_hours_ago = DateTime.add(DateTime.utc_now(), -2, :hour)
|
||||||
run_time = two_hours_ago |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
|
run_time = two_hours_ago |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
|
||||||
|
|
@ -85,7 +81,10 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
||||||
grid_data = merge_native_duct_data(grid_data, run_time, forecast_hour)
|
grid_data = merge_native_duct_data(grid_data, run_time, forecast_hour)
|
||||||
:erlang.garbage_collect()
|
:erlang.garbage_collect()
|
||||||
|
|
||||||
Weather.warm_grid_cache_and_broadcast(valid_time)
|
# Build weather cache rows from in-memory grid_data — avoids the ~20s
|
||||||
|
# JSONB round trip that was crashing the worker before f01–f18 could run.
|
||||||
|
rows = Weather.build_grid_cache_rows(grid_data, valid_time)
|
||||||
|
GridCache.broadcast_put(valid_time, rows)
|
||||||
|
|
||||||
Phoenix.PubSub.broadcast(
|
Phoenix.PubSub.broadcast(
|
||||||
Microwaveprop.PubSub,
|
Microwaveprop.PubSub,
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,158 @@ defmodule Microwaveprop.WeatherGridTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "build_grid_cache_rows/2" do
|
||||||
|
test "returns an empty list for empty grid_data" do
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
assert Weather.build_grid_cache_rows(%{}, now) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "builds a derived row for each in-memory grid point without hitting the database" do
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
profile = [
|
||||||
|
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
|
||||||
|
%{"pres" => 925.0, "tmpc" => 20.0, "dwpc" => 14.0, "hght" => 800.0},
|
||||||
|
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0},
|
||||||
|
%{"pres" => 700.0, "tmpc" => 5.0, "dwpc" => -2.0, "hght" => 3100.0},
|
||||||
|
%{"pres" => 500.0, "tmpc" => -15.0, "dwpc" => -25.0, "hght" => 5600.0}
|
||||||
|
]
|
||||||
|
|
||||||
|
grid_data = %{
|
||||||
|
{32.125, -97.375} => %{
|
||||||
|
profile: profile,
|
||||||
|
surface_temp_c: 25.0,
|
||||||
|
surface_dewpoint_c: 18.0,
|
||||||
|
surface_pressure_mb: 1013.0,
|
||||||
|
hpbl_m: 500.0,
|
||||||
|
pwat_mm: 30.0
|
||||||
|
},
|
||||||
|
{32.250, -97.375} => %{
|
||||||
|
profile: profile,
|
||||||
|
surface_temp_c: 26.0,
|
||||||
|
surface_dewpoint_c: 19.0,
|
||||||
|
surface_pressure_mb: 1012.0,
|
||||||
|
hpbl_m: 600.0,
|
||||||
|
pwat_mm: 32.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Weather.build_grid_cache_rows(grid_data, now)
|
||||||
|
|
||||||
|
assert length(result) == 2
|
||||||
|
|
||||||
|
point = Enum.find(result, &(&1.lat == 32.125))
|
||||||
|
assert point.lon == -97.375
|
||||||
|
assert point.valid_time == now
|
||||||
|
assert point.temperature == 25.0
|
||||||
|
assert point.dewpoint_depression == 7.0
|
||||||
|
assert point.bl_height == 500.0
|
||||||
|
assert point.pwat == 30.0
|
||||||
|
assert point.surface_pressure_mb == 1013.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "computes surface_refractivity and min_refractivity_gradient from the profile" do
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
profile = [
|
||||||
|
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
|
||||||
|
%{"pres" => 925.0, "tmpc" => 20.0, "dwpc" => 14.0, "hght" => 800.0},
|
||||||
|
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0},
|
||||||
|
%{"pres" => 700.0, "tmpc" => 5.0, "dwpc" => -2.0, "hght" => 3100.0},
|
||||||
|
%{"pres" => 500.0, "tmpc" => -15.0, "dwpc" => -25.0, "hght" => 5600.0}
|
||||||
|
]
|
||||||
|
|
||||||
|
grid_data = %{
|
||||||
|
{32.125, -97.375} => %{
|
||||||
|
profile: profile,
|
||||||
|
surface_temp_c: 25.0,
|
||||||
|
surface_dewpoint_c: 18.0,
|
||||||
|
surface_pressure_mb: 1013.0,
|
||||||
|
hpbl_m: 500.0,
|
||||||
|
pwat_mm: 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[point] = Weather.build_grid_cache_rows(grid_data, now)
|
||||||
|
|
||||||
|
assert is_float(point.surface_refractivity)
|
||||||
|
assert is_float(point.refractivity_gradient)
|
||||||
|
assert is_float(point.surface_rh)
|
||||||
|
assert point.temp_850mb == 15.0
|
||||||
|
assert point.dewpoint_850mb == 10.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips points with missing or physically impossible surface temperature" do
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
profile = [
|
||||||
|
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
|
||||||
|
%{"pres" => 925.0, "tmpc" => 20.0, "dwpc" => 14.0, "hght" => 800.0},
|
||||||
|
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}
|
||||||
|
]
|
||||||
|
|
||||||
|
grid_data = %{
|
||||||
|
{32.125, -97.375} => %{
|
||||||
|
profile: profile,
|
||||||
|
surface_temp_c: nil,
|
||||||
|
surface_dewpoint_c: nil,
|
||||||
|
surface_pressure_mb: 1013.0,
|
||||||
|
hpbl_m: 500.0,
|
||||||
|
pwat_mm: 30.0
|
||||||
|
},
|
||||||
|
{32.250, -97.375} => %{
|
||||||
|
profile: profile,
|
||||||
|
surface_temp_c: 250.0,
|
||||||
|
surface_dewpoint_c: 200.0,
|
||||||
|
surface_pressure_mb: 1013.0,
|
||||||
|
hpbl_m: 500.0,
|
||||||
|
pwat_mm: 30.0
|
||||||
|
},
|
||||||
|
{32.375, -97.375} => %{
|
||||||
|
profile: profile,
|
||||||
|
surface_temp_c: 25.0,
|
||||||
|
surface_dewpoint_c: 18.0,
|
||||||
|
surface_pressure_mb: 1013.0,
|
||||||
|
hpbl_m: 500.0,
|
||||||
|
pwat_mm: 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Weather.build_grid_cache_rows(grid_data, now)
|
||||||
|
|
||||||
|
assert length(result) == 1
|
||||||
|
assert hd(result).lat == 32.375
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips raw profile/duct_characteristics/surface_temp_c/surface_dewpoint_c fields" do
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
profile = [
|
||||||
|
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
|
||||||
|
%{"pres" => 925.0, "tmpc" => 20.0, "dwpc" => 14.0, "hght" => 800.0},
|
||||||
|
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}
|
||||||
|
]
|
||||||
|
|
||||||
|
grid_data = %{
|
||||||
|
{32.125, -97.375} => %{
|
||||||
|
profile: profile,
|
||||||
|
surface_temp_c: 25.0,
|
||||||
|
surface_dewpoint_c: 18.0,
|
||||||
|
surface_pressure_mb: 1013.0,
|
||||||
|
hpbl_m: 500.0,
|
||||||
|
pwat_mm: 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[point] = Weather.build_grid_cache_rows(grid_data, now)
|
||||||
|
|
||||||
|
refute Map.has_key?(point, :profile)
|
||||||
|
refute Map.has_key?(point, :duct_characteristics)
|
||||||
|
refute Map.has_key?(point, :surface_temp_c)
|
||||||
|
refute Map.has_key?(point, :surface_dewpoint_c)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "weather_point_detail/3" do
|
describe "weather_point_detail/3" do
|
||||||
test "returns all fields for a single grid point" do
|
test "returns all fields for a single grid point" do
|
||||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue