Commit graph

286 commits

Author SHA1 Message Date
764643bc62
test: expand coverage and refactor to idiomatic style
Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
  + `if/else` with a classifier that pattern-matches the error reason
  directly, returning `:transient | :permanent`, and dispatch via
  `case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
  step on the row/col comprehensions so an image box whose centre
  lands outside the raster collapses to an empty iteration instead
  of firing Elixir 1.19's ambiguous-range warning. Adds a regression
  test for the right-edge box.

Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
  duct_thickness, best_duct_freq, duct_usable_for_band,
  distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
  unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
  tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
  handling; parse_snmpget_output OID normalisation and junk-line
  tolerance.
- HrrrNativeClient native_level_count, native_variables,
  native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
  SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
  Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
  monotonic in RH, finite across the operating envelope) + idempotent
  nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
  output; latlon_to_pixel round-trip for every grid cell.

Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
  current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.

Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
2026-04-23 13:28:42 -05:00
cfb0c51d2f
test: add property coverage for grid snaps, backoff, notes
Pure-function invariants that example tests can't express at scale:

- Weather.round_to_iemre_grid/2, HrrrClient.nearest_hrrr_hour/1,
  NexradClient.round_to_5min/1, NarrClient.snap_to_analysis_hour/1:
  each is idempotent, its output satisfies a grid/slot-membership
  predicate, and lies within a bounded distance of the input.
- NarrClient.in_coverage?/1: coverage_end is an exclusive upper
  bound; any timestamp after it is out of coverage.
- IemreFetchWorker.backoff/1: result is in [120, 21_600], monotonic
  non-decreasing in attempt, equals 120*2^(attempt-1) pre-cap.
- Contact submission_changeset notes field: every non-blank string
  up to 2000 chars validates; any longer string is rejected.

22 new properties (139 → 161 including existing); all 2,300 tests
green, credo clean.
2026-04-23 13:05:06 -05:00
063e9e3ae4
fix(grid-tasks): reclaim orphan running rows on hourly seed
Rust workers (prop-grid-rs) that die mid-claim (SIGKILL, OOM, node
drain) leave grid_tasks rows stuck in status='running' forever,
because claim_next uses FOR UPDATE SKIP LOCKED and nothing resets the
orphan. The /status page then shows a permanent spinner with stale
f00/f10/f18 badges — currently 21 rows claimed as far back as
2026-04-20.

GridTaskEnqueuer.reclaim_stale_running/1 flips rows whose claimed_at
is older than 15 minutes back to 'queued'. Rows that have already
burned through 5 claim/reclaim cycles become 'failed' with a
reclaim-orphan error so the next hourly seed can replace them.
Wired into PropagationGridWorker.seed_chain/0 so it runs every :05
cron tick before new rows are seeded.

Also rename the status panel "Retrying" column to "Failed" — it was
always showing terminal `failed` rows, never retrying ones.
2026-04-23 12:57:39 -05:00
47ba8d3b6d
fix(enrichment): stub IEMRE on permanent failure + bump hot CPU
IemreFetchWorker now writes an empty stub observation when IEM returns
a permanent-failure status (404, 422), mirroring the existing {:ok, []}
path. The backfill cron's next tick sees the stub via
has_iemre_observation?/3, generates no job, and mark_status!/3 flips
the contact's iemre_status from :queued to :complete — draining the
51 contacts that have been stuck behind cancelled out-of-grid IEMRE
jobs.

Raise hot pod CPU limit 2 → 3 so BEAM gets 3 schedulers online.
Observed run queues of [2, 10, 0, 0, 0, 0] on the 2-scheduler
configuration during the :05 propagation chain, starving /live and
/health probe handlers and tripping intermittent liveness timeouts.
2026-04-23 12:26:09 -05:00
b2ff27945d
feat(adif): carry NOTES / COMMENT into contact notes
ADIF records frequently include operator commentary in the
<NOTES> field (multi-line, detailed) or <COMMENT> (short
one-liner). Now that contacts have a notes column, pass that
value through the import pipeline instead of dropping it.

Preference is NOTES over COMMENT to match the ADIF 3.1.4 spec's
intended split — NOTES is the richer field. Whitespace-only
values collapse to nil so a program that always emits a blank
NOTES tag doesn't fill every row with an empty string.

Tests cover NOTES capture, COMMENT fallback, NOTES-over-COMMENT
preference, and the absent-field nil case. ADIF upload blurb
lists the new carry-over so users see it before confirming.
2026-04-23 11:05:39 -05:00
cd3950f366
feat(contacts): add free-form notes field
Operators want a place to jot observations that aren't captured by
the structured fields — weather anecdotes, propagation mode
commentary, equipment details, band conditions. Add a text column
on contacts plus the two ingest paths users submit through:

  * /submit single-contact form gets a 3-row textarea under the
    other fields. Optional, max 2000 chars, with a live length cap
    via maxlength. Whitespace-only input collapses to NULL so the
    column reflects "no notes" rather than an empty string.
  * CSV importer recognises a `notes` (or `note`) column via the
    existing header_aliases table and flows values straight through
    submission_changeset. Sample template gets a matching example
    row with embedded commas so the quoted-field round-trip is
    exercised on download.

ADIF import and the refinement/refinement-notify paths are out of
scope — users specifically asked for CSV + single-contact. Tests
cover the changeset (accept/blank/over-length), CSV header parsing
(plain + RFC-4180 quoted), and LiveView form submit end-to-end.
2026-04-23 11:02:27 -05:00
004f302374
fix(hrrr): mark contacts :unavailable on failed fetch tasks
When the Rust hrrr-point-worker marks a task as `failed` (upstream
`idx HTTP 404` or wgrib2 decode error on old archive formats), the
corresponding contacts sit at `hrrr_status = :queued` until the
generic 3-day stale-queued reconcile flips them. Speed that up: on
every BackfillEnqueue tick, join contacts to `hrrr_fetch_tasks` by
rounded HRRR hour and flip any `:queued` contact whose cycle is
permanently `failed` to `:unavailable` immediately.

Motivation: a handful of 2018-08-04/05 cycles (t20z/t21z/t15z/t17z)
are genuinely missing `wrfsfcf`/`wrfprsf` files on the NOAA S3
archive — other hours on the same dates are fine, so this is an
upstream data gap rather than a structural pre-2019 issue. Users
see an accurate "Partial" marker on those contacts instead of a
three-day `:queued` stall.

Tested: 4 new cases covering rounding, failed-vs-done, and the
`hrrr not in types` skip branch; full suite 2283/0.
2026-04-22 17:46:51 -05:00
bfddb898bf
fix(backfill): stop clobbering :complete enrichment statuses
Three bugs were letting mechanism/terrain/radar contacts ping-pong
between :complete and :queued on every backfill cron:

1. enqueue_for_contact/2 now filters out types that are already
   :complete on the contact before building jobs. Previously
   mark_status!/3 demoted every non-empty jobs_by_type entry back to
   :queued, overwriting the :complete that workers had just set.
2. reconcile_stale_queued/1 now also flips mechanism_status and
   radar_status back to :complete when the companion data
   (propagation_mechanism / contact_common_volume_radar) already
   exists. Drains the existing ~14k row backlog on the first post-
   deploy tick.
3. IemClient.parse_iemre_json/1 accepts "" and nil so a 200-with-
   empty-body IEMRE response doesn't FunctionClauseError the worker
   through four retry attempts.
2026-04-22 13:54:05 -05:00
e8c252932a
fix(ionosphere): disable TLS verify for GIRO to unbreak ionosonde fetch
lgdc.uml.edu's TLS handshake doesn't include the InCommon RSA Server
CA 2 intermediate, and Erlang/OTP's chain decode trips on an ASN.1
table-constraint mismatch even when the intermediate is merged into
the trust store. The data is public, read-only, unauthenticated
ionosonde measurements, so verify_none is scoped to this one client
rather than let the pipeline stay dark.
2026-04-22 09:49:03 -05:00
c192d6fd9e
fix(propagation): bound ScoreCache to 18-hour forecast window
Hot pods were restart-looping every ~25 minutes on liveness probe
timeouts. Root cause: ScoreCacheReconciler mirrored every .prop file
from NFS into ETS, including 7 days of GEFS Day 2-7 forecasts. With
23 bands × 43 valid_times × ~2 MB per grid = 1.95 GB in the
propagation_score_cache table alone; GC sweeps starved the
scheduler enough that /live dropped its 3 s budget.

The /map UI only ever requests the [now-1h, now+18h] window. Share
that bound as Propagation.hot_cache_window/0 and apply it in the
reconciler's disk-scan path plus NotifyListener's post-warm prune.
Long-horizon GEFS files stay on disk and are still served via the
lazy read_from_disk_and_cache path when requested directly.

Adds ScoreCache.prune_outside_window/2 (inclusive bounds) and
updates the reconciler tests to use relative-to-now timestamps
since hardcoded fixture dates now drift out of window.
2026-04-22 08:34:45 -05:00
ee7809e17b
hardening: Rust worker circuit-breaker + delete wall-clock sleep from beacon test
**Rust workers exit after 30 consecutive claim_next failures.**
Both `prop-grid-rs` and `hrrr-point-worker` ran a `while` loop that
slept 10s and retried forever on `claim_next` errors. If Postgres
went unreachable, pods would stay alive but useless — no circuit
breaker, no liveness probe, just an infinite stream of error logs.
Now we increment a consecutive-failure counter and exit with an
error once it hits 30 (~5 minutes of DB unavailability). k8s
restart policy then cycles the pod, which both triggers an
operator alert and gives us a fresh sqlx pool on the way back up.
A successful claim (Some or None) resets the counter so one
flaky query doesn't poison it.

**BeaconMonitorsTest: drop the Process.sleep(1100).** The test
relied on wall-clock gaps to disambiguate `inserted_at` values on
the `:utc_datetime` (second-precision) timestamps — one full
second of sleep per run, and on shared CI it could still flake
when both inserts landed in the same second. Replaced with an
explicit `Repo.update_all` that back-dates the first row by one
second. 1.1s faster, deterministic.
2026-04-21 18:02:46 -05:00
d3ca2e5791
fix(map): dedupe duplicated forecast-timeline chips + extract Mechanism helpers
The main /map timeline was rendering each forecast hour twice
('Now 22:00' + '0h 22:00', '+1h 23:00' + '+1h 23:00', …).

Root cause: during the rolling `.ntms` → `.prop` rename window,
both extensions live on NFS for the same valid_time. `list_score_files`
matches either, so `list_valid_times_raw` mapped 2 files → 2 DateTimes
with no dedupe. Propagation.available_valid_times inherited the dup,
push_timeline pushed it to the JS hook, renderTimeline drew one chip
per entry — hence the visible duplicate.

Fix: `Enum.uniq` before the sort. Regression test writes a `.prop`
file, copies it as the same valid_time's `.ntms` legacy twin, and
asserts list_valid_times returns a single DateTime.

Also land a small refactor that was queued behind this: move the three
pure mechanism display helpers (label/1, badge_class/1, explainer/1)
out of the 2500-line contact_live/show.ex into a new
MicrowavepropWeb.ContactLive.Mechanism module. No behavior change — just
shrinks the LiveView and gives the display mapping a testable home.
2026-04-21 17:21:11 -05:00
26ef50c965
fix(ingest): dedupe ASOS duplicates + shrink SWPC decode errors
Two unrelated production warnings from the same log window:

WeatherFetchWorker raised Postgrex 21000 cardinality_violation on
ASOS upserts when IEM returned two METAR records for the same
timestamp (routine + special at the same minute). Dedupe by
(station_id, observed_at) in upsert_surface_observations, keeping
the last occurrence since IEM's ordering is chronological.

SwpcClient.fetch_xrays warned with the full truncated response
body (~650 KB of GOES X-ray JSON) dumped into the log aggregator
whenever SWPC's CDN cut the response mid-stream. Intercept
Jason.DecodeError in fetch_and_parse and format it short: keep
the byte position and an 80-char preview, drop the rest.
2026-04-21 16:39:17 -05:00
8ec5d895ee
chore(logs): suppress Phoenix Sent log for /live liveness probe
/live fires every 10s per pod; the request line and Sent 200 response
from Plug.Telemetry drowned out real requests. log_level/1 now returns
false for /live too, same treatment /health already got.
2026-04-21 16:34:17 -05:00
0c98707091
feat: harden /map analysis breakdown + move Plausible to root layout
Two fixes cover the blank "analysis breakdown" panel the user reported:

1. Bound the point_detail fallback lookback to 24h. Previously
   factors_from_fallback_profile would happily use a week-old analysis
   profile when no current one existed — now anything older than 24h
   returns an empty factor map so the UI can surface "breakdown
   unavailable" instead of silently misattributing.

2. Move the Plausible analytics snippet from Layouts.app into
   root.html.heex. Full-bleed LiveViews (/map, /contacts/map,
   /weather, home) bypass Layouts.app, so the snippet only loaded on
   the navbar-wrapped pages. root.html.heex is loaded once per HTTP
   document so coverage is now universal.

Added ~30 tests locking both down:
  • point_detail fallback: exact-time wins, 24h boundary accepted,
    30h-stale rejected, multi-band coverage, missing-cell degrades to
    empty factors, default-valid_time path
  • analytics_test.exs: Plausible script present on 12 representative
    pages, exactly once, loaded async, surviving a login round-trip

Also fixed pre-existing credo issues per standing rule: shortened the
map_live_test ETS match_delete call, extracted a function from the
deeply-nested hrrr_point_enqueuer.enqueue_for_contacts/1 cond, and
swapped Mix.Tasks.Rust.Golden's IO.inspect for Mix.shell().info.
2026-04-21 15:56:30 -05:00
410a1374fe
refactor(scores): rename file extension .ntms -> .prop with legacy reads
Score-grid files now land at `<band>/<iso>.prop` with a 4-byte "PROP"
magic header. Readers still accept the legacy `.ntms` extension and
"NTMS" magic so a rolling deploy doesn't invalidate any file already
on the shared NFS mount — legacy files age out through normal
retention.

Applied both sides of the seam: Elixir ScoresFile + regex parsers,
Rust scores_file writer + decoder. Updated the comments in all
modules that mention the extension (NotifyListener, Reconciler,
gefs_fetch_worker, map_live) and the runbook diagram. talos5 is a
DB host now, not a Rust worker — corrected the runbook accordingly.
2026-04-21 15:54:12 -05:00
084e6b6224
refactor(db): use explicit on_conflict set clauses over :replace_all_except
Convert every context/worker upsert that used the catch-all
`:replace_all_except` form to the explicit `{:replace, [:col1, ...]}`
form. Reviewers can now see exactly which columns an upsert overwrites,
and adding a new column to a schema no longer silently opts it into the
update path.

Behaviour is preserved bit-for-bit: each new explicit list contains
every column the old `:replace_all_except` would have overwritten.

Touched:
- Microwaveprop.SpaceWeather (Kp / F10.7 / X-ray shared helper)
- Microwaveprop.Ionosphere (observation upsert)
- NexradWorker, HrrrNativeGridWorker (insert_all grids)
- CommonVolumeRadarWorker, RadarFrameWorker (per-contact stats)

Also pins the conflict behaviour for the ContactCommonVolumeRadar
upsert path in RadarFrameWorkerTest so a future column addition that
isn't reflected in the explicit list fails loudly.
2026-04-21 14:22:15 -05:00
15ce50365f
perf(concurrency): partition Task.Supervisor to remove bottleneck on heavy async_stream paths
Wrap a Task.Supervisor in a PartitionSupervisor (one partition per
scheduler) and route the four sustained async_stream callers through
`{:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor, self()}}`.
Concurrent work — the hourly PropagationGridWorker pulling f00-f18
HRRR range downloads, the GEFS worker scoring its grid, and the
Recalibrator's 20-way positive/negative fan-out — no longer funnels
through a single supervisor PID.

Light/one-shot Task.async + Task.start sites (hrrr_client surface/
pressure fan-out, application boot warmup, weather fire-and-forget)
are left alone; partitioning only helps under sustained concurrency.
2026-04-21 14:20:29 -05:00
52a32032a3
feat(logging): structured JSON logs in prod via LOG_FORMAT env var
The ingress + Grafana pipeline parses JSON-formatted log lines; Elixir's
default text formatter was the odd format everywhere else, forcing Loki
to fall back to free-text parsing. Swap the `:default` :logger handler's
formatter to `LoggerJSON.Formatters.Basic` when `LOG_FORMAT=json`.

Defaults: `LOG_FORMAT=text` for dev/test (no behaviour change), `json`
for prod. Whitelisted metadata covers the fields we actually correlate
on — `request_id`, `trace_id`, `worker`, `queue`, `job_id`.

OTP 28 / Elixir 1.19 exposes `Logger.Formatter.new/1` but ships no
JSON encoder of its own, so `logger_json` (~> 7.0) carries the rendering.
The test pokes the formatter directly rather than round-tripping through
ExUnit's capture_log, which replaces the handler and so can't see the
installed formatter's output.
2026-04-21 14:17:54 -05:00
f446977048
feat(observability): attach Oban exception telemetry tap
PromEx.Plugins.Oban counts job exceptions as a Prometheus counter but
does not tell us which worker/queue failed, whether the job is about to
be retried, or the exception class. This adds a telemetry handler on
[:oban, :job, :exception] that:

- emits a single structured Logger.error with worker, queue, job_id,
  attempt, max_attempts, retry_exhausted?, kind, reason class, and
  args-keys summary (never values — args can contain PII/tokens);
- dispatches opt-in per-worker recovery callbacks
  (on_permanent_failure/1 when retries exhausted,
  on_transient_failure/1 otherwise);
- catches callback failures so a bug in one worker's recovery path
  cannot detach the handler or break logging for other jobs;
- does not emit a second telemetry event, so PromEx counts are not
  double-counted.

Handler is attached from Application.start/2 after the Oban supervisor
starts.
2026-04-21 14:13:53 -05:00
1591ac740d
feat(cache): jitter ScoreCacheReconciler sweep interval to avoid thundering herd
Every pod was rescheduling its sweep on a fixed 60 s `Process.send_after`
tick, so N replicas hit the shared NFS `/data/scores` mount and Postgres
in lock-step every minute. Add a uniform jitter of up to 20 s on top of
the 60 s base (effective range 60-80 s) so per-replica schedules drift
apart.

Expose `next_sweep_interval/0,2` as a public helper so the randomized
window can be tested deterministically with `:rand` draws rather than
mocking timers. Existing reschedule test passes `jitter_max_ms: 0` to
keep its 2 s wait window honest.
2026-04-21 14:12:31 -05:00
32ba5de456
test(property): add property tests for parsing + domain conversions 2026-04-21 13:57:47 -05:00
c60d56281f
test(property): add property tests for propagation scoring + CIDR matcher 2026-04-21 13:57:12 -05:00
4c1013cdbd
test(property): add property tests for terrain + atmospheric math
Cover the pure-math invariants of the ITU-R P.526-16 diffraction
helpers, the haversine metric on Geo, and the Magnus / dry-adiabat
helpers in the skew-T renderer. 29 properties, one invariant each.
2026-04-21 13:56:39 -05:00
71e8f53142
fix: stabilize flaky tests + silence PromEx Oban poller in test
- HrrrClient idx-cache test only invalidated the surface idx URL, but
  fetch_profile also fetches a pressure idx. Previous runs' state for
  the pressure key decided whether the counter landed at 1 (prior run
  cached it, pass) or 2 (cold, fail). Invalidate both + assert 2 total
  fetches to reflect the actual code path.
- CsvImportTest deadlocked against other async DataCase tests when
  inline Oban child jobs upserted iemre_observations/terrain_profiles
  with a shared conflict target. Flip to async: false — same fix as
  ContactWeatherEnqueueWorkerTest earlier this session.
- PromEx.Plugins.Oban runs a 5s telemetry_poller that queries the DB,
  but its poller PID has no sandbox connection in test and crashed
  with DBConnection.OwnershipError on every tick, spamming the log.
  Gate the plugin on a config flag and skip it in config/test.exs;
  prod behaviour unchanged.
2026-04-21 13:49:07 -05:00
b7261e772c
feat(observability): emit telemetry for NotifyListener warm + GridCache hit/miss
Band-warm failures in the Rust propagation_ready pipeline were only
surfaced via Logger.warning, so Prometheus had no way to see them.
The /weather GridCache hit/miss was likewise invisible while its
/map counterpart (ScoreCache) already reported cache ratio.

- NotifyListener emits [:microwaveprop, :propagation, :notify_listener, :warm]
  with %{ok, err} measurements and %{valid_time} metadata after every
  propagation_ready notification. Public handle_propagation_ready/1 so
  tests can exercise the warm+telemetry path without Postgrex.
- GridCache.fetch/1, fetch_bounds/2, and fetch_point/3 emit
  [:microwaveprop, :weather, :grid_cache, :lookup] with a :hit | :miss
  metadata key on every ETS lookup.
2026-04-21 13:30:26 -05:00
ccf6779fb1
feat(observability): instrument PropagationGridWorker with Instrument.span
Wraps the hourly grid_tasks seed with
[:microwaveprop, :propagation, :grid_worker, :perform] so Prometheus
can see duration, success, and failure for the chain-seed cron.
Emits a :seeded event with the queued_tasks count and an :exception
event on GridTaskEnqueuer failure so silent seed errors show up in
the existing PromEx dashboards instead of dying as a lone Logger.info.
2026-04-21 13:29:09 -05:00
b874020a87
fix(cache): add periodic TTL sweeper to bound ETS growth 2026-04-21 13:19:53 -05:00
5255a6ba63
perf(weather): batch ASOS observation upserts into a single insert_all
IemClient.fetch_asos returns 24-288 rows per station per call, each
previously triggering an individual UPDATE-conflict round-trip via
Weather.upsert_surface_observation/2. On the Turing Pi 2 Postgres
node that per-row latency dominates ingestion time.

Add Weather.upsert_surface_observations/2, a bulk variant that collapses
the rows into a single Repo.insert_all with the same
update-only-when-changed on_conflict predicate and (station_id,
observed_at) conflict target. Switch WeatherFetchWorker to use it.
2026-04-21 13:19:53 -05:00
8e73583926
feat(workers): add GridCachePruneWorker
The Microwaveprop.Weather.GridCache ETS table grew unbounded — each
hourly PropagationGridWorker run plus peer broadcast_put added ~92k
points per forecast hour, so long-lived pods were strong OOM
candidates. GridCache already exposed prune_older_than/1 but nothing
called it.

Adds an Oban worker on the :propagation queue (lower priority than
the grid chain) that prunes entries older than 3 hours. Scheduled
hourly at :15 in dev, prod, and the base crontab.
2026-04-21 13:19:52 -05:00
7ab426c01d
fix(workers): propagate transient errors so Oban retries + counters fire
CommonVolumeRadarWorker and MechanismClassifyWorker were both
returning :ok on upstream failures, so Prometheus job-failure counters
stayed at zero during multi-hour NEXRAD outages and classifier bugs
went unnoticed.

- CommonVolumeRadarWorker: distinguish permanent (4xx) from transient
  errors using the same pattern as RadarFrameWorker. Permanent errors
  still mark :unavailable + :ok so the backfill stops re-queueing them.
  Transient errors return {:error, reason} and leave radar_status alone
  so the next retry can succeed.
- MechanismClassifyWorker: keep the row-status :failed update so
  operators can see which contacts blew up, but return
  {:error, inspect(e)} from the rescue instead of swallowing it as :ok.
2026-04-21 13:15:27 -05:00
f9e0331a2c
test: fix three pre-existing test flakes
- MapLiveTest timestamp indicator: earlier tests in the suite leave
  score files in the shared `:propagation_scores_dir` and warm the 5 s
  `list_valid_times` cache; clear both in setup so the "No data"
  assertion sees the state the test documents.
- PropagationPruneWorkerTest: `old = now - 3h` collides with the
  worker's cutoff (also `now - 3h`) once both are truncated to the
  second, so they land equal when test + worker run in the same wall
  clock second. Shift `old` by an extra -1s for unconditional ordering.
- ContactWeatherEnqueueWorkerTest: flip to async: false — perform/1
  runs inline Oban child jobs that upsert rows with a shared conflict
  target, and parallel DataCase siblings hit "ShareLock deadlock_detected"
  intermittently.
2026-04-21 13:07:01 -05:00
733a7f5bf1
fix(review): address code-reviewer findings
Fixes flagged by the code-reviewer agent's pass over the session's
commits (cc9220b..7b78a25):

- Propagation.warm_cache_and_broadcast/2 now uses ScoresFile.read/2
  directly and returns {:ok, :ok} | {:error, :enoent | :invalid_format}.
  Previously it called ScoresFile.read_bounds/3 which silently returns
  [] on missing/corrupt files, poisoning ScoreCache with an empty grid
  that the reconciler couldn't heal.
- NotifyListener.warm_band/2 and ScoreCacheReconciler.warm_one/2 now
  pattern-match {:error, reason} and skip (log, return :error) instead
  of caching empty. rescue clauses kept as defense-in-depth for
  unexpected faults in PubSub.broadcast / ETS writes.
- ScoresFile.extract_points/2 promoted to @doc public — callers that
  need to distinguish missing file from empty grid can feed read/2
  payloads here themselves.
- Weather.build_grid_cache_row/4: replaced || fallbacks with prefer/3
  helper (Map.fetch) so a legitimate persisted ducting_detected: false
  is not clobbered by a derived-from-sounding true.
- Weather.hrrr_data_fully_present?/1 @spec tightened from map() to
  Contact.t() | field-constrained map, plus is_nil(qso_timestamp)
  guard so callers with partial contacts get a clean false rather
  than a HrrrClient.nearest_hrrr_hour/1 crash.
- AdminTaskWorker.native_derive bulk-UPDATE: chunk reduced 2000 → 500
  and wrapped in try/rescue with per-row fallback on Postgrex errors
  so a single bad row doesn't kill the remaining 1999 in its chunk.
- Runbook FM3 rewritten to match the actual code path (no rescue;
  explicit {:error, _} pattern match). FM5 clarifies the surviving
  PropagationGridWorker is a cron-fired seed worker, not a fallback
  compute path.
- Dialyzer: strict flags added (:error_handling, :unknown,
  :unmatched_returns, :extra_return, :missing_return). Baseline
  emitted 130 warnings, mostly discarded Task.start/PubSub/Logger
  returns; a follow-up will tighten those and backfill @specs.

New tests:
- ScoreCacheReconciler GenServer lifecycle: run_on_start true/false,
  interval_ms rescheduling, info-level log line.
- Weather.hrrr_data_fully_present?/1: nil qso_timestamp returns false.
- Weather.build_grid_cache_rows/2: explicit ducting_detected: false
  on profile beats derived true from sounding params.
- RadarFrameWorker: pins the NexradClient "NEXRAD n0q HTTP <code>"
  error string contract so permanent_error?/1 classification doesn't
  silently regress if the client's error format changes.
2026-04-21 10:09:46 -05:00
7b78a2574c
fix(workers): 3 bug/perf fixes from codebase review
1. GridCache: auto-release fill lock when the claimer process crashes.
   claim_fill/1 + release_fill/1 go through the GenServer so the
   server can Process.monitor the caller and clean up the ETS entry
   on :DOWN. Clear/0 now resets both the data table and the lock
   table. Fixes a latent bug where a crashed fill leaked the lock
   indefinitely, preventing every subsequent /weather mount for that
   valid_time from claiming and leaving cache cold.

2. RadarFrameWorker: distinguish permanent vs transient fetch errors.
   404 from the IEM n0q archive is permanent (file will never exist)
   and marks contacts :unavailable as before. Any other error shape
   (5xx, timeout, transport failure) now returns {:error, reason}
   so Oban retries — previously those also pinned contacts at
   :unavailable after a transient outage.

3. AdminTaskWorker.native_derive: replace per-row Repo.update_all
   (N round-trips + N fsyncs) with one UPDATE ... FROM unnest(...)
   per 2000-row batch. For the 10k-profile budget this is one
   network round trip per chunk instead of 10k, and one fsync per
   chunk instead of 10k. Restructured the clause to separate
   derivation (pure) from persistence (I/O).

All three changes are test-covered (grid_cache_test auto-release
test, radar_frame_worker_test 5xx + transport tests, existing
admin_task_worker_test native_derive coverage exercises the new
bulk path). Also drops the scorer_diff no-op test that was
verifying the clause removed in 61da51c.
2026-04-21 09:53:13 -05:00
71c134d93b
chore(cleanup): drop dead scorer_diff + DB grid fallback; harden NotifyListener
- AdminTaskWorker: remove scorer_diff no-op clause (propagation_scores table
  was dropped pre-cutover; queue is drained).
- Weather: remove load_weather_grid_from_db/1 fallback — Rust writes
  ProfilesFile for every forecast hour, so the DB path was unreachable
  in practice and loaded stale pre-cutover data when it fired.
- Weather.build_grid_cache_row/4 now prefers persisted fields from the
  ProfilesFile profile map (surface_refractivity, min_refractivity_gradient,
  ducting_detected, duct_characteristics) over re-deriving from the raw
  profile, matching the old DB-path semantics.
- NotifyListener: wrap per-band warm in try/rescue so one corrupt or
  mid-rename .ntms file doesn't abort the remaining bands. Failures log
  a warning with band + valid_time; successes still emit one info line.
- weather_grid_test: rewrite insert_hrrr_profile helper to write
  ProfilesFile directly and drop the "only returns points on 0.125 grid"
  test (filter was a property of the deleted DB fallback; Rust writes
  only grid points by construction).
2026-04-21 09:42:35 -05:00
f4f44b5ebe
refactor(contacts): rename propagation_mechanism_status -> mechanism_status
Column name now matches the type key used everywhere else:
  :hrrr -> hrrr_status, :weather -> weather_status,
  :terrain -> terrain_status, :mechanism -> mechanism_status

Drops the @status_column_overrides remap in BackfillEnqueueWorker
(status_column/1 is now a straight :"#{type}_status" derivation) and
the long-form references in Contact, ContactWeatherEnqueueWorker,
and MechanismClassifyWorker. Migration is a metadata-only RENAME
COLUMN + RENAME INDEX — safe to land in prod (no table rewrite,
brief catalog lock only).
2026-04-21 09:28:37 -05:00
33be6d008f
feat(propagation): add ScoreCacheReconciler + pipeline runbook
Periodic 60s sweep re-warms ScoreCache from /data/scores whenever
{band, valid_time} pairs are on disk but absent in local ETS.
Covers the three silent-fail modes of the NOTIFY/LISTEN path:
dropped notifies on reconnect, missing listener, and stale NFS
reads. Each pod reconciles its own cache — no PubSub fan-out,
no coordination required. Rescue on File.Error handles the
rare window where an atomic-rename write races the reader.

docs/runbook_propagation_pipeline.md names every failure mode
in the Rust↔Elixir seam and what recovers it.
2026-04-21 09:24:16 -05:00
baab3c09f5
refactor(weather): move hrrr_data_fully_present? from enqueuer to Weather
Pure domain check — "does this contact already have HRRR profiles at
every path point for its QSO hour" — has no business living in an
Oban worker. Weather already owns has_hrrr_profile?/3 and
round_to_hrrr_grid/2, so consolidation keeps HRRR knowledge in one
context and leaves ContactWeatherEnqueueWorker focused on job
coordination.
2026-04-21 09:20:42 -05:00
cc9220b8d2
fix(backfill): flip hrrr_status :queued -> :complete when data already present
hrrr_placeholder_jobs previously returned [contact.id] for any
post-2014 contact with pos1, regardless of whether the HRRR
profiles were already in the DB. That meant BackfillEnqueueWorker
re-flagged the same ~22k contacts as :queued on every 30 min
cron tick forever — HrrrPointEnqueuer saw 0 missing points and
inserted nothing, but the status field never advanced.

Now the placeholder also returns [] when every path point has
an hrrr_profile row, so mark_hrrr_status!/3's empty-list branch
flips the contact to :complete directly. Genuinely-missing
contacts still flow through HrrrPointEnqueuer unchanged.
2026-04-21 08:19:52 -05:00
e641fce278
fix(backfill): re-probe :unavailable contacts after a 24 h cooldown
The Contact list's 'Partial' badge was sticky: once a worker gave up on
a contact (upstream 404, no nearby station, IEM throttling) or the
stale-queued reconciler flipped it to :unavailable after 3 days, the
backfill cron never looked at it again. Upstream archives heal over
time — IEM backfills gaps, new ASOS stations come online, Rust worker
gains capability — so rows stayed 'Partial' that shouldn't have.

Changes:

* BackfillEnqueueWorker.type_filter now also matches <status>_status =
  :unavailable when contacts.updated_at is older than a 24 h cooldown.
  enqueue_for_contact is idempotent: if there's still nothing to
  fetch it'll flip the status to :complete on the empty-jobs branch;
  if new data shows up it builds fresh jobs.
* reconcile_stale_queued_to_unavailable now bumps updated_at when
  flipping to :unavailable so the cooldown starts fresh — otherwise
  the main loop would pick it right back up on the same cron tick
  (stale updated_at < any reasonable cooldown) and immediately
  transition :unavailable → :complete, defeating the :unavailable
  signal entirely.

Also:

* Drop ScoresFile.point_score/3, dead since read_point moved to the
  pread fast path in commit bd3b114.
* Clear Microwaveprop.Cache in WeatherGridTest setup. The new
  ProfilesFile read cache is keyed by (base_dir, valid_time); tests
  in this file reuse DateTime.utc_now() truncated to seconds and
  share the default base_dir, so a prior test's cached read leaks
  into the next one's expectations.
2026-04-20 16:46:32 -05:00
9bf96349ad
perf(telemetry): cut hot-path overhead on scores_at cache hits
Three small wins from a telemetry audit:

1. Propagation.scores_at/3 wrapped every call in Instrument.span, firing
   two handler dispatches that dominated the ~10µs ETS lookup on cache
   hits. The map's LiveView fires this on every pan + point-click, so
   hot-path latency was mostly telemetry. Span now wraps only the miss
   branch (where disk IO makes the duration signal meaningful); hit path
   still emits the cheap hit/miss counter the cache-ratio panel reads.

2. Oban queue-depth poller: 10s → 30s. Every replica independently
   GROUP-BYs oban_jobs, so each extra replica paid ~3 redundant queries
   per minute for a gauge that moves on the hour-scale anyway.

3. Dropped summary(phoenix.endpoint.start.system_time): summarizing a
   wall-clock timestamp produces no useful aggregate — stop.duration
   below it is the meaningful signal.
2026-04-20 16:12:01 -05:00
c78cb977a0
test(backfill): verify pos1/pos2 are derived from grids before status-marking 2026-04-20 15:04:21 -05:00
9950aa11c2
fix(backfill): mark path enrichments :unavailable when pos2 is missing
Contacts lacking pos2 had terrain/radar/mechanism stuck on :pending, so
BackfillEnqueueWorker re-scanned them every cron cycle. Mark these
statuses :unavailable so the enqueue filter excludes them; a later pos2
edit resets them back to :pending via Radio.reset_enrichment_statuses/2.
2026-04-20 15:02:33 -05:00
ddedd241de
fix(radar): dedupe RadarFrameWorker jobs by frame_ts
BackfillEnqueueWorker re-enqueued every :queued contact each cycle, and
RadarFrameWorker had no unique clause, so the same 5-min NEXRAD frame
got scheduled repeatedly with overlapping contact lists. Prod had 9,134
duplicate available jobs across 1,061 distinct frames — 89% waste.

Two coupled changes so contacts aren't stranded by the dedup:

  1. Worker now queries eligible contacts by the frame's 5-min bucket
     instead of the args' contact_ids list. A contact that flips to
     :queued between the enqueue and the job running gets picked up.

  2. unique: [frame_ts] on states [available, scheduled, retryable].
     :executing intentionally excluded — a contact that flips to :queued
     mid-execution can still enqueue a follow-up instead of getting
     stuck behind a running job that missed it.
2026-04-20 14:21:55 -05:00
b6b5945002
feat(radar): batch per NEXRAD frame instead of per contact
CommonVolumeRadarWorker was enqueuing one job per contact — every
job fetched + decoded the ~5 MB n0q PNG for its own 5-min frame.
At current backlog depth that's 17,588 contacts across just 1,747
distinct 5-min frames, i.e. ~10 contacts per frame paying for the
same PNG decode over and over.

New RadarFrameWorker takes a batch of contact_ids sharing one frame,
fetches + decodes the frame ONCE, then walks the in-memory pixel
buffer per contact. build_radar_jobs/1 in ContactWeatherEnqueueWorker
now groups the input contacts by their rounded 5-min timestamp and
emits one RadarFrameWorker job per frame instead of one
CommonVolumeRadarWorker per contact.

process_frame/4 is public so the same code path is used both by
perform/1 (production fetch) and tests (pre-decoded pixel buffer);
keeps the happy-path test hermetic without hand-crafting a PNG.

CommonVolumeRadarWorker stays in the tree — still used from the
single-contact submit path where batching is pointless and the
aggregate_stats/5 pure helper is a dependency of the new batched
worker.

Expected impact on the backfill: ~10x fewer fetch + decode cycles,
so the 17k queued-contact backlog drains in minutes instead of ~50.
2026-04-20 12:12:52 -05:00
7416583c27
feat(hrrr): route per-QSO enrichment through hrrr_fetch_tasks (Stream C Elixir)
Phase 3 Stream C Elixir-side: HrrrFetchWorker is deleted; per-QSO HRRR
enrichment now writes to the new hrrr_fetch_tasks table for the Rust
hrrr-point-worker to drain.

Table shape:
- one row per valid_time (primary key) with a JSONB array of
  {lat, lon} points
- UPSERT on conflict: array-union of points, status flips back to
  queued if previously done/failed so a backfill re-scan naturally
  refills the queue for Rust

Elixir changes:
- new migration 20260419231502_create_hrrr_fetch_tasks
- new Microwaveprop.Weather.HrrrPointEnqueuer with enqueue/1 and
  enqueue_for_contacts/1. Pre-2014 contacts (NARR's territory)
  are skipped here so hrrr_status can pin them to :unavailable.
- ContactWeatherEnqueueWorker: build_hrrr_jobs/1 removed; single-
  contact path and batch perform/1 both route through
  HrrrPointEnqueuer.enqueue_for_contacts/1. A placeholder jobs-list
  is kept just to feed mark_hrrr_status!.
- contact_live/show.ex retry button enqueues via the same path.
- :hrrr queue removed from dev/config/runtime.exs
- HrrrFetchWorker module + test deleted

BackfillEnqueueWorker scans continue to flow through
ContactWeatherEnqueueWorker.enqueue_for_contact (unchanged), so the
30-min reconcile refills hrrr_fetch_tasks automatically.

4 new tests cover the routing, pre-2014 skip, UPSERT-union, and
status-reset-on-reschedule behaviour. Rust-side hrrr-point-worker
binary + k8s deployment land in the next commits.
2026-04-19 18:22:22 -05:00
cd7f2fc2b8
feat(propagation): Phase 3 Stream A cutover — Rust owns f00..f18
The hourly cron now only seeds grid_tasks. The chain step, native-duct
merge, NEXRAD merge, commercial-link merge, scoring, ProfilesFile
write, and band-score writes all moved to rust/prop_grid_rs.

Elixir changes:
- GridTaskEnqueuer.seed_with_analysis/1: inserts 1 kind='analysis' row
  (f00) + 18 kind='forecast' rows (f01..f18).
- PropagationGridWorker: stripped from 423 LOC to a thin seeder.
  perform(%{}) → GridTaskEnqueuer.seed_with_analysis.
  Deleted: process_forecast_hour, merge_native_duct_data,
  merge_nexrad_data, merge_commercial_link_data, compute_scores_*,
  persist_profiles, record_run_timing (Rust emits spans to Prometheus
  instead), apply_nexrad_observations, apply_duct_grid, timed helpers.
  Test rewritten for the new shape: 0 Oban fan-out jobs, 19 grid_tasks
  rows with the expected kind distribution.

HrrrNativeClient and NexradClient remain — they have other callers
(HrrrNativeGridWorker for per-QSO duct batch; NexradWorker and
CommonVolumeRadarWorker for per-contact radar). Only f00's direct
use moved.
2026-04-19 18:13:41 -05:00
d41ced5850
feat(profiles_file): read Rust-written MessagePack alongside legacy ETF
Phase 3 Stream A: Rust's prop-grid-rs writes f00 profile files as
MessagePack (c4f309c). Elixir needs to read both formats during the
cutover window and indefinitely afterward — .mp.gz wins when both
exist.

Reader:
- path_for/1 stays on .etf.gz (Elixir legacy writer)
- mp_path_for/1 returns the .mp.gz sibling Rust lands
- read/1 prefers .mp.gz, falls back to .etf.gz, :enoent if neither
- decode_mp_body handles the top-level {v, valid_time, cells} shape
- normalize_profile atomizes a whitelist of known keys (surface_*,
  native_min_gradient, ducts[], profile[] levels, ...) and leaves
  everything else as strings so no String.to_atom bomb is possible
- Directory scan picks up both extensions so retain_window and
  prune_older_than also work uniformly; list_valid_times dedupes
  by timestamp

Adds msgpax ~> 2.4 dependency. 4 new tests cover format preference,
key atomization, :enoent path, and dedupe.
2026-04-19 18:10:23 -05:00
48708621c5
chore(mrms): delete pipeline — consumer AsosAdjustmentWorker already disabled
MrmsFetchWorker fired every 2 min on the :propagation queue, decoding an
MRMS PrecipRate GRIB2 into MrmsCache. Its only consumer,
AsosAdjustmentWorker, is disabled in all configs (dev / config / runtime).
Net effect: 30 GRIB2 decodes/hour of dead work on hot pods.

Removed:
- MrmsFetchWorker, MrmsClient, MrmsCache and their tests
- cron entries in config.exs, dev.exs, runtime.exs
- mrms_req_options stub in test.exs
- MrmsCache from supervision tree in application.ex
- stale MRMS references in PropagationGridWorker docstring and test

Also adds docs/plans/2026-04-19-rust-migration-phase3.md which tracks
the broader Stream A (f00 port) and Stream C (HrrrFetchWorker port)
follow-on work.
2026-04-19 17:25:59 -05:00
65693ed415
feat(prop): Phase 2 cutover — Rust owns f01–f18
Rust `prop-grid-rs` has been validated end-to-end on talos5 on the
streamed-bands image (main-1776635096-6d91461): real chain steps
complete in ~7 s each and the peak-heap refactor landed.

Changes:
- `PropagationGridWorker.seed_chain` now enqueues a single f00 Oban
  job instead of f00–f18. The f00 analysis-hour keeps its enrichment
  (native-level duct, NEXRAD composite, commercial-link boost,
  ProfilesFile write) and the GridCache broadcast.
- `GridTaskEnqueuer.seed/1` is called again so grid_tasks gets the
  18 forecast-hour rows the Rust worker claims.
- Test asserts only one Oban job is enqueued and the matching 18
  grid_tasks rows land in the DB.
- `deployment-grid-rs.yaml` flips `PROP_SCORES_DIR` from
  `/data/scores_shadow` to `/data/scores` so Rust writes to the live
  score tree. No file collision — Elixir only writes the f00 files.

The hot pod memory shrink (6 Gi → 1 Gi) is deferred until one full
hourly cycle runs clean under the new split.
2026-04-19 16:51:14 -05:00