Reworks the post-fetch half of the propagation pipeline. Fetch and GRIB2
decode were already cheap — measured against a live HRRR cycle, all 39
pressure messages decode via `wgrib2 -lola` in 0.29 s and the 31 MB
byte-range fetch takes ~3 s — so nothing here touches the decoder. All the
cost was downstream.
Also fixes a broken NOTIFY that made every chain step run up to 5 times.
pg_notify
`NOTIFY propagation_ready, $1` is a Postgres syntax error: NOTIFY is a
utility statement whose payload must be a literal, so a bind raises
42601. It shared a transaction with the `status='done'` UPDATE, so every
successful step rolled back, stayed 'running', and was requeued by
reclaim_stale_running up to @max_reclaim_attempts times. Elixir's
NotifyListener never fired either, so ScoreCache warm and the
"propagation:updated" fan-out were dead.
FieldGrid
A decoded grid was HashMap<(i32,i32), HashMap<Arc<str>, f32>> — a dense
rectangular grid stored as ~95k nested hash maps, costing ~4.6M inserts
on decode, ~3.7M on merge and ~14M lookups across three derivation
passes. wgrib2 -lola already emits one dense row-major f32 block per
message, so keep it: dense per-message planes, names hashed once per
grid into plane ids, NaN as the missing sentinel. This is what forced
PROP_GRID_RS_PARALLELISM=1 under a 3Gi limit.
Fused pass
Three 95k-cell derivation passes plus 23 band-major scoring passes over
a staged Vec<(f64,f64,Conditions,BandInvariants)> (~19MB re-streamed 23
times) collapse into one pass: levels extracted once per cell, all 23
bands scored while the cell is hot, scores accumulated cell-major so
rayon chunks own disjoint slices. Scores land straight in the dense
score-file body — no ScorePoint scatter.
.pgrid
The profile artifact was an rmpv tree plus gzip -9, written 30x an hour,
and ProfilesFile.read_point/3 gunzipped and unpacked the entire 95k-cell
file to return one cell on every map click and Skew-T load. Replaced
with a dense cell-major f32 record array carrying a self-describing
field table. Elixir reads it via :file.pread; .mp.gz and .etf.gz remain
readable so files written before this drain out of the 48h window.
Measured on a full CONUS grid (95,073 cells x 48 planes x 23 bands):
derive + score + build artifacts 0.022 s
profile write 3.957 s -> 0.006 s (22.0 MB -> 22.4 MB on disk)
single-cell read whole-file decode -> 0.5 us
23 score files 0.003 s
Also
- hrrr_points: batched UNNEST upsert replacing one awaited INSERT per
point. Keeps ON CONFLICT DO UPDATE — the PSKR sampler's two-pass loop
depends on it.
- fetcher: real semaphore capping in-flight ranges at
MAX_PARALLEL_RANGES, which the comment claimed but the code did not do
(it spawned all 27 while the connection pool was sized for 8).
- metrics: per-stage histogram. Only chain-step and decode durations
were instrumented, which is why the write cost stayed invisible.
- profiles_file: parse_valid_time anchors on the known extension set, so
sibling-suffixed names like <iso>.hrdps.prop no longer parse as
<iso>.hrdps and vanish from prune and list operations.
- PROP_GRID_RS_PARALLELISM 1 -> 3. Memory limit held at 3Gi until RSS is
observed at the new parallelism.
- cargo fmt over the crate; worker.rs, hrdps_fetcher.rs and nexrad.rs
were already unformatted at HEAD and the pre-commit hook gates on it.
HRDPS still runs at 0.5 degrees. wgrib2 -lola scales linearly in output
points on rotated lat/lon (12.5 s wall, 202 s CPU for one message at
0.125 degrees) because it has no inverse projection for those grids; a raw
native dump is 0.32 s. The fix is decode-once plus a closed-form
rotated-pole index, left for a follow-up.
391 lines
14 KiB
Elixir
391 lines
14 KiB
Elixir
defmodule Microwaveprop.Propagation.ProfilesFile do
|
||
@moduledoc """
|
||
On-disk store for the raw, enriched HRRR grid_data that
|
||
`PropagationGridWorker` produces for the f00 analysis hour. One
|
||
compressed ETF file per `valid_time` lands at
|
||
`{base_dir}/profiles/{iso}.etf.gz`. Used by `Propagation.point_detail/4`
|
||
to rebuild the factor breakdown for a clicked cell without a database
|
||
round trip.
|
||
|
||
Only f00 is persisted — forecast hours intentionally skip the factor
|
||
breakdown on the map to keep the scoring+upsert phase under a minute
|
||
(see `Propagation.compute_scores/3`).
|
||
|
||
## Atomic writes
|
||
|
||
Files are written through `rename(2)`: `path.tmp.<uniq>` → rename to
|
||
the final path. On the shared NFS mount the rename is atomic, so a
|
||
concurrent reader sees either the old file, the new file, or nothing —
|
||
never a partial write.
|
||
"""
|
||
|
||
alias Microwaveprop.Propagation.Grid
|
||
alias Microwaveprop.Propagation.Pgrid
|
||
|
||
@doc "Base directory the profile store lives under."
|
||
@spec base_dir() :: String.t()
|
||
def base_dir do
|
||
Path.join(
|
||
Application.get_env(:microwaveprop, :propagation_scores_dir, "/data/scores"),
|
||
"profiles"
|
||
)
|
||
end
|
||
|
||
@doc "Absolute path for the Elixir-written ETF profile file covering `valid_time`."
|
||
@spec path_for(DateTime.t()) :: String.t()
|
||
def path_for(%DateTime{} = valid_time) do
|
||
Path.join(base_dir(), "#{iso_key(valid_time)}.etf.gz")
|
||
end
|
||
|
||
@doc """
|
||
Absolute path for the Rust-written MessagePack profile file covering
|
||
`valid_time`. Rust's `prop_grid_rs::profiles_file::write_atomic`
|
||
lands here. Reader prefers `.mp.gz` when both formats coexist during
|
||
Phase 3 Stream A cutover.
|
||
"""
|
||
@spec mp_path_for(DateTime.t()) :: String.t()
|
||
def mp_path_for(%DateTime{} = valid_time) do
|
||
Path.join(base_dir(), "#{iso_key(valid_time)}.mp.gz")
|
||
end
|
||
|
||
defp iso_key(%DateTime{} = valid_time) do
|
||
valid_time |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||
end
|
||
|
||
# Keys Rust writes as strings; Elixir callers read them as atoms.
|
||
# Anything outside this whitelist stays a string and is ignored by
|
||
# downstream consumers (weather.ex uses the atom form throughout).
|
||
@mp_atom_keys MapSet.new([
|
||
"native_min_gradient",
|
||
"best_duct_freq_ghz",
|
||
"max_duct_thickness_m",
|
||
"duct_count",
|
||
"wind_u",
|
||
"wind_v",
|
||
"cloud_cover_pct",
|
||
"precip_mm",
|
||
"ducts",
|
||
"base_m",
|
||
"top_m",
|
||
"thickness_m",
|
||
"m_deficit",
|
||
"min_freq_ghz",
|
||
"surface_temp_c",
|
||
"surface_dewpoint_c",
|
||
"surface_pressure_mb",
|
||
"surface_refractivity",
|
||
"hpbl_m",
|
||
"pwat_mm",
|
||
"min_refractivity_gradient",
|
||
"ducting_detected",
|
||
"duct_characteristics",
|
||
"nexrad_max_reflectivity_dbz",
|
||
"commercial_link_degradation",
|
||
"degradation_db",
|
||
"baseline_dbm",
|
||
"current_dbm",
|
||
"n_links",
|
||
"profile",
|
||
"pres_mb",
|
||
"hght_m",
|
||
"tmpc",
|
||
"dwpc"
|
||
])
|
||
|
||
@doc """
|
||
Persist the enriched grid_data (`%{{lat, lon} => profile}`) for
|
||
`valid_time`. Writes compressed ETF; lat/lon keys are rounded to the
|
||
grid step so later point lookups can snap user-provided coordinates.
|
||
"""
|
||
@spec write!(DateTime.t(), %{{float(), float()} => map()}) :: :ok
|
||
def write!(%DateTime{} = valid_time, grid_data) when is_map(grid_data) do
|
||
path = path_for(valid_time)
|
||
File.mkdir_p!(Path.dirname(path))
|
||
|
||
snapped =
|
||
Map.new(grid_data, fn {{lat, lon}, profile} ->
|
||
{{Float.round(lat, 3), Float.round(lon, 3)}, profile}
|
||
end)
|
||
|
||
binary = :erlang.term_to_binary(snapped, [:compressed])
|
||
|
||
tmp = path <> ".tmp." <> unique_suffix()
|
||
File.write!(tmp, binary, [:binary])
|
||
File.rename!(tmp, path)
|
||
invalidate_caches_for(valid_time)
|
||
:ok
|
||
end
|
||
|
||
@doc """
|
||
Read the full persisted grid_data for `valid_time`. Returns
|
||
`{:ok, %{{lat, lon} => profile}}` or `{:error, :enoent}` if the file
|
||
doesn't exist. Used by `Microwaveprop.Weather` to warm `GridCache`
|
||
on pod startup without touching the database.
|
||
|
||
Does *not* pass `[:safe]` to `binary_to_term/1`. The file was written
|
||
by `write!/2` in the same code base, so we trust it. The previous
|
||
`[:safe]` flag was rejecting our own files at app-startup warm time
|
||
because some atoms in the persisted profile (`:base_m`, `:top_m`,
|
||
`:thickness_m`, `:min_freq_ghz`, `:native_min_gradient`, etc.) live
|
||
in modules that aren't eagerly loaded during boot, so they weren't
|
||
in the runtime atom table when `warm_grid_cache_from_latest_profile`
|
||
ran. The fix is not "load more modules eagerly" — it's "trust files
|
||
we wrote ourselves."
|
||
"""
|
||
@spec read(DateTime.t()) :: {:ok, %{{float(), float()} => map()}} | {:error, :enoent}
|
||
def read(%DateTime{} = valid_time) do
|
||
# Decoded profile maps are ~10 MB each (92k cells × atmospheric
|
||
# profile). A map click fires `point_detail` → `factors_for` which
|
||
# calls this, and the user scrubbing the forecast timeline hits
|
||
# the same valid_time repeatedly within seconds. Caching the full
|
||
# decoded map for 5s turns scrub clicks into ETS lookups.
|
||
Microwaveprop.Cache.fetch_or_store({__MODULE__, :read, base_dir(), valid_time}, 5_000, fn ->
|
||
do_read(valid_time)
|
||
end)
|
||
end
|
||
|
||
defp do_read(valid_time) do
|
||
# Preference order is newest-format-first. Rust now writes only
|
||
# `.pgrid` (dense cell-major f32, see `Microwaveprop.Propagation.Pgrid`);
|
||
# the `.mp.gz` and `.etf.gz` branches remain so pods reading files
|
||
# written before the cutover still render while those drain out of
|
||
# the 48 h retention window.
|
||
cond do
|
||
Pgrid.exists?(valid_time) -> Pgrid.read(valid_time)
|
||
File.exists?(mp_path_for(valid_time)) -> read_mp(valid_time)
|
||
File.exists?(path_for(valid_time)) -> read_etf(valid_time)
|
||
true -> {:error, :enoent}
|
||
end
|
||
end
|
||
|
||
defp read_etf(valid_time) do
|
||
case File.read(path_for(valid_time)) do
|
||
# `:safe` rejects unknown atoms and external function refs in the
|
||
# decoded term — defense-in-depth in case the on-disk file is ever
|
||
# tampered with (it is owned by the prop pipeline, but the cost is
|
||
# zero and the failure mode without :safe is atom-table exhaustion).
|
||
{:ok, binary} -> {:ok, :erlang.binary_to_term(binary, [:safe])}
|
||
{:error, _} -> {:error, :enoent}
|
||
end
|
||
end
|
||
|
||
defp read_mp(valid_time) do
|
||
with {:ok, gz} <- File.read(mp_path_for(valid_time)),
|
||
{:ok, binary} <- gunzip_safe(gz),
|
||
{:ok, body} <- Msgpax.unpack(binary) do
|
||
{:ok, decode_mp_body(body)}
|
||
else
|
||
{:error, _} -> {:error, :enoent}
|
||
end
|
||
end
|
||
|
||
defp gunzip_safe(data) do
|
||
{:ok, :zlib.gunzip(data)}
|
||
rescue
|
||
_ -> {:error, :corrupt}
|
||
end
|
||
|
||
defp decode_mp_body(%{"cells" => cells}) when is_list(cells) do
|
||
Map.new(cells, fn cell ->
|
||
lat = cell |> Map.get("lat") |> to_float()
|
||
lon = cell |> Map.get("lon") |> to_float()
|
||
profile = cell |> Map.get("profile", %{}) |> normalize_profile()
|
||
{{lat, lon}, profile}
|
||
end)
|
||
end
|
||
|
||
defp decode_mp_body(_), do: %{}
|
||
|
||
defp normalize_profile(value) when is_map(value) do
|
||
Map.new(value, fn {k, v} ->
|
||
key = if is_binary(k) and MapSet.member?(@mp_atom_keys, k), do: String.to_existing_atom(k), else: k
|
||
{key, normalize_profile(v)}
|
||
end)
|
||
end
|
||
|
||
defp normalize_profile(value) when is_list(value) do
|
||
Enum.map(value, &normalize_profile/1)
|
||
end
|
||
|
||
defp normalize_profile(value), do: value
|
||
|
||
defp to_float(v) when is_float(v), do: v
|
||
defp to_float(v) when is_integer(v), do: v * 1.0
|
||
defp to_float(_), do: 0.0
|
||
|
||
@doc """
|
||
Read a single profile for `(valid_time, lat, lon)`. Returns `nil` if
|
||
the file is missing or the point has no profile. Input lat/lon are
|
||
snapped to the nearest grid cell before lookup.
|
||
"""
|
||
@spec read_point(DateTime.t(), float(), float()) :: map() | nil
|
||
def read_point(%DateTime{} = valid_time, lat, lon) do
|
||
{snapped_lat, snapped_lon} = snap(lat, lon)
|
||
|
||
# `.pgrid` supports true random access: one `pread` of the cell's
|
||
# record. Do NOT route this through `read/1` — that would decode the
|
||
# whole grid to answer a single point lookup, which is exactly the
|
||
# cost this format exists to remove. The legacy branches have no
|
||
# random access, so they still go through the cached full read.
|
||
if Pgrid.exists?(valid_time) do
|
||
Pgrid.read_point(valid_time, snapped_lat, snapped_lon)
|
||
else
|
||
case read(valid_time) do
|
||
{:ok, grid_data} -> Map.get(grid_data, {snapped_lat, snapped_lon})
|
||
{:error, _} -> nil
|
||
end
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Delete profile files whose valid_time is strictly before `cutoff`.
|
||
Returns the number of files removed.
|
||
"""
|
||
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
|
||
def prune_older_than(%DateTime{} = cutoff) do
|
||
cutoff_unix = DateTime.to_unix(cutoff)
|
||
|
||
deleted =
|
||
base_dir()
|
||
|> list_profile_files()
|
||
|> Enum.reduce(0, fn {path, valid_time_unix}, acc ->
|
||
if valid_time_unix < cutoff_unix do
|
||
_ = File.rm(path)
|
||
acc + 1
|
||
else
|
||
acc
|
||
end
|
||
end)
|
||
|
||
if deleted > 0, do: invalidate_all_caches()
|
||
deleted
|
||
end
|
||
|
||
@doc """
|
||
Keep only profile files whose valid_time is inside the closed window
|
||
`[run_time, run_time + max_forecast_hour * 3600]`. Mirrors
|
||
`ScoresFile.retain_window/2` and is called at the end of a
|
||
`PropagationGridWorker` chain.
|
||
"""
|
||
@spec retain_window(DateTime.t(), non_neg_integer()) :: non_neg_integer()
|
||
def retain_window(%DateTime{} = run_time, max_forecast_hour) when max_forecast_hour >= 0 do
|
||
lo = DateTime.to_unix(run_time)
|
||
hi = lo + max_forecast_hour * 3600
|
||
|
||
deleted =
|
||
base_dir()
|
||
|> list_profile_files()
|
||
|> Enum.reduce(0, fn {path, valid_time_unix}, acc ->
|
||
if valid_time_unix < lo or valid_time_unix > hi do
|
||
_ = File.rm(path)
|
||
acc + 1
|
||
else
|
||
acc
|
||
end
|
||
end)
|
||
|
||
if deleted > 0, do: invalidate_all_caches()
|
||
deleted
|
||
end
|
||
|
||
@doc """
|
||
Latest valid_time of any persisted profile, or nil if the store is
|
||
empty.
|
||
"""
|
||
@spec latest_valid_time() :: DateTime.t() | nil
|
||
def latest_valid_time do
|
||
case list_profile_files(base_dir()) do
|
||
[] ->
|
||
nil
|
||
|
||
files ->
|
||
files
|
||
|> Enum.map(fn {_path, unix} -> unix end)
|
||
|> Enum.max()
|
||
|> DateTime.from_unix!()
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Every persisted valid_time, sorted ascending. Used to build the
|
||
/weather forecast timeline from the on-disk f00..f48 profile files.
|
||
"""
|
||
@spec list_valid_times() :: [DateTime.t()]
|
||
def list_valid_times do
|
||
# Hit on every point_detail click when the clicked valid_time
|
||
# lacks its own profile and fallback scan searches for the
|
||
# nearest past analysis. NFS dir-listing on every click was a
|
||
# visible chunk of click latency.
|
||
Microwaveprop.Cache.fetch_or_store({__MODULE__, :list_valid_times, base_dir()}, 5_000, fn ->
|
||
base_dir()
|
||
|> list_profile_files()
|
||
|> Enum.map(fn {_path, unix} -> unix end)
|
||
|> Enum.uniq()
|
||
|> Enum.map(&DateTime.from_unix!/1)
|
||
|> Enum.sort(DateTime)
|
||
end)
|
||
end
|
||
|
||
defp invalidate_caches_for(valid_time) do
|
||
dir = base_dir()
|
||
Microwaveprop.Cache.invalidate({__MODULE__, :read, dir, valid_time})
|
||
Microwaveprop.Cache.invalidate({__MODULE__, :list_valid_times, dir})
|
||
:ok
|
||
end
|
||
|
||
defp invalidate_all_caches do
|
||
Microwaveprop.Cache.match_delete({{__MODULE__, :read, :_, :_}, :_, :_})
|
||
Microwaveprop.Cache.invalidate({__MODULE__, :list_valid_times, base_dir()})
|
||
end
|
||
|
||
@doc """
|
||
Snap a lat/lon pair to the nearest grid cell key used in the decoded
|
||
profile map. Public so callers that have already loaded the full grid
|
||
via `read/1` can do their own keyed lookups without re-fetching.
|
||
"""
|
||
@spec snap(float(), float()) :: {float(), float()}
|
||
def snap(lat, lon) do
|
||
step = Grid.step()
|
||
snapped_lat = Float.round(Float.round(lat / step) * step, 3)
|
||
snapped_lon = Float.round(Float.round(lon / step) * step, 3)
|
||
{snapped_lat, snapped_lon}
|
||
end
|
||
|
||
defp list_profile_files(dir) do
|
||
case File.ls(dir) do
|
||
{:ok, entries} ->
|
||
for entry <- entries,
|
||
valid_time_unix = parse_valid_time(entry),
|
||
valid_time_unix != nil do
|
||
{Path.join(dir, entry), valid_time_unix}
|
||
end
|
||
|
||
_ ->
|
||
[]
|
||
end
|
||
end
|
||
|
||
defp parse_valid_time(filename) do
|
||
# Matches `.pgrid` (current), `.mp.gz` (Rust Phase 3 Stream A) and
|
||
# `.etf.gz` (Elixir legacy). If several exist for the same valid_time
|
||
# the directory listing yields multiple entries with the same
|
||
# timestamp; the pipeline downstream of list_valid_times/0 uniq-sorts,
|
||
# and read/1 prefers the newest format.
|
||
#
|
||
# Anchoring on the *known* extension set (rather than a permissive
|
||
# `\.(.+)$`) is what keeps sibling-suffixed files such as
|
||
# `<iso>.hrdps.prop` from parsing as `<iso>.hrdps` and then failing
|
||
# `DateTime.from_iso8601/1` — the failure mode that made HRDPS score
|
||
# files invisible to every prune and list operation.
|
||
with [_, iso] <- Regex.run(~r/^(.+)\.(?:pgrid|etf\.gz|mp\.gz)$/, filename),
|
||
{:ok, dt, _} <- DateTime.from_iso8601(iso) do
|
||
DateTime.to_unix(dt)
|
||
else
|
||
_ -> nil
|
||
end
|
||
end
|
||
|
||
defp unique_suffix do
|
||
"#{System.system_time(:nanosecond)}.#{:erlang.unique_integer([:positive])}"
|
||
end
|
||
end
|