Commit graph

1297 commits

Author SHA1 Message Date
FluxCD
2f5b37f7c5 chore: update prop image to git.mcintire.me/graham/prop:main-1776783205-7b78a25 [skip ci] 2026-04-21 14:55:46 +00: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
FluxCD
e5991d324a chore: update prop image to git.mcintire.me/graham/prop:main-1776782574-71c134d [skip ci] 2026-04-21 14:46:43 +00: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
FluxCD
61da51c03f chore: update prop image to git.mcintire.me/graham/prop:main-1776781734-f4f44b5 [skip ci] 2026-04-21 14:31:32 +00: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
FluxCD
360a0e32c1 chore: update prop image to git.mcintire.me/graham/prop:main-1776781470-33be6d0 [skip ci] 2026-04-21 14:27:27 +00: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
a81e55e348
refactor(propagation): drop ShadowComparator (Phase 1 only)
Phase 2 cutover completed in 65693ed — Rust prop-grid-rs owns
f01-f18 production writes and the shadow path has been dead
for weeks. No callers in lib/, test/, or mix tasks.
2026-04-21 09:20:42 -05:00
FluxCD
9ced0eab87 chore: update prop image to git.mcintire.me/graham/prop:main-1776780470-fbbb8b2 [skip ci] 2026-04-21 14:16:16 +00:00
fbbb8b2e1e
feat(logging): add method + request_path to log metadata
Every Sent log line now carries method and path so noisy traffic
(probes, scrapers, redirects) is identifiable without a tracer.
RemoteIp plug already sets remote_ip metadata; add method and
request_path alongside it and register the extra keys with the
default formatter.
2026-04-21 09:07:31 -05:00
FluxCD
cd2962687a chore: update prop image to git.mcintire.me/graham/prop:main-1776777603-cc9220b [skip ci] 2026-04-21 13:23:57 +00: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
FluxCD
cb9d9fb30b chore: update prop image to git.mcintire.me/graham/prop:main-1776752742-c96bdca [skip ci] 2026-04-21 06:35:16 +00:00
c96bdca7b7
fix(backfill): cap cron run at 2000 contacts/tick to avoid 17 min discard
Unlimited BackfillEnqueueWorker runs loaded all ~36k enrichable
contacts and hit Oban's ~17 min execution timeout, discarding the
parent job every cron tick. Enrichment was landing partially
(jobs enqueued before the timeout) but the loop never completed
and no success signal was emitted. A per-tick limit of 2000 keeps
each run well under any timeout while still draining the backlog
at 4000/hour across the */30 schedule.
2026-04-21 01:24:45 -05:00
FluxCD
e3d7f5b2c2 chore: update prop image to git.mcintire.me/graham/prop:main-1776722189-30525a1 [skip ci] 2026-04-20 21:59:07 +00:00
30525a18f3
perf(status): collapse 7 contact-count queries to a single FILTER scan
count_unprocessed_raw fired seven separate COUNT(*) scans on the
contacts table every 2 s refresh (total, terrain, hrrr, weather, iemre,
radar, all-done). Each one went through the 58k+ row table
independently, so the status page was hitting Postgres with ~3.5 full
scans per second of connected viewer time.

A single query with COUNT(*) FILTER clauses pulls every count in one
sequential scan. Same result, roughly 1/7 the CPU on Postgres.
2026-04-20 16:56:15 -05:00
FluxCD
ff241afa6e chore: update prop image to git.mcintire.me/graham/prop:main-1776721927-aebf391 [skip ci] 2026-04-20 21:55:01 +00:00
aebf3911ce
perf(map): debounce preload_forecast + single-pass score filter
Two wins in the /map hot path.

1. preload_forecast fired on every Leaflet moveend — which arrives in
   bursts during pan/zoom — and each firing read + filtered + shipped
   18 forecast-hour score lists (up to 90k cells per hour at full
   CONUS view) over the LiveView websocket. Now debounced to the
   trailing edge: the user must stop moving for 750 ms before we pay
   the preload cost. select_band and propagation_updated go through
   the same scheduler so a storm of PubSub updates during an hourly
   chain also coalesces.

2. ScoreCache.grid_to_filtered_list walked the 92k-cell grid twice
   (Enum.filter then Enum.map) and allocated an intermediate tuple
   list between them. Enum.reduce emits only in-bounds result maps
   directly — halves map traversal + drops the tuple intermediate.
2026-04-20 16:51:52 -05:00
FluxCD
9815bd691e chore: update prop image to git.mcintire.me/graham/prop:main-1776721607-e641fce [skip ci] 2026-04-20 21:49:58 +00: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
FluxCD
796fb22d4b chore: update prop image to git.mcintire.me/graham/prop:main-1776721085-ee732d0 [skip ci] 2026-04-20 21:45:54 +00:00
ee732d03b1
fix(contact-detail): re-enqueue HRRR on every page view when profile missing
The 'Fetching HRRR atmospheric data' spinner stayed up forever for
contacts stuck in :queued without a matching hrrr_fetch_tasks row.
Ways a contact lands in that state:

  - HrrrPointEnqueuer.enqueue/1 raises, the rescue swallows the error
    and returns {:ok, 0}, but the caller still marked the contact
    :queued before it noticed the failure.
  - Rust worker marks a (valid_time) task :done with a points array
    that never included this contact's point, so no profile is ever
    written for it.
  - hrrr_fetch_tasks row is cleaned up by retention before the contact
    page loads.

Drop the hrrr_status != :queued short-circuit and always call the
enqueuer when the profile is nil. The ON CONFLICT clause in
HrrrPointEnqueuer.enqueue/1 unions points into the existing row,
resets :done/:failed rows back to :queued with attempt=0, and no-ops
when the point is already present — so re-entering on every page view
is cheap and self-healing.
2026-04-20 16:37:14 -05:00
FluxCD
00101ca928 chore: update prop image to git.mcintire.me/graham/prop:main-1776720629-bd3b114 [skip ci] 2026-04-20 21:34:50 +00:00
bd3b1148a4
perf(map): collapse point_detail/forecast NFS I/O + silence /metrics logs
Clicks on the map were hanging for 1-3s, triggering the Phoenix topbar
progress bar visible to the user. Three hot-path improvements:

1. ScoresFile.read_point now uses a two-range :file.pread/2 — reads the
   33-byte header + one cell byte (~60 bytes) instead of the full ~93 KB
   file. point_forecast walks 19 forecast hours per click, so the old
   full-read path was the dominant cost on cold cache.

2. ProfilesFile.read/1 and list_valid_times/0 cached via
   Microwaveprop.Cache (5s TTL). Decoded profile maps are ~10 MB each
   (92k cells); timeline scrub that re-clicks the same valid_time
   within seconds now hits ETS instead of re-gunzipping + decoding.
   Cache keys include base_dir so test setup that swaps dirs doesn't
   see stale entries. Writes + prune + retain_window invalidate.

3. Endpoint log_level now filters /metrics the same way it already
   filters /health — Prometheus scrapes every 5s and was producing
   visible log spam.

Pod-local ETS is right for this workload (per-click read, tiny working
set); Valkey / shared memstore would not help here since each pod needs
its own fast-path lookup. File a follow-up if cross-pod score lookups
ever show up in flame graphs.
2026-04-20 16:30:16 -05:00
FluxCD
77b54a1642 chore: update prop image to git.mcintire.me/graham/prop:main-1776719536-9bf9634 [skip ci] 2026-04-20 21:14:47 +00: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
FluxCD
6463b7261f chore: update prop image to git.mcintire.me/graham/prop:main-1776715484-c78cb97 [skip ci] 2026-04-20 20:07:30 +00: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
FluxCD
c10cf176e7 chore: update prop image to git.mcintire.me/graham/prop:main-1776714901-52ce44d [skip ci] 2026-04-20 19:57:23 +00:00
52ce44d738
feat(map): restore point-detail panel across navigation 2026-04-20 14:54:44 -05:00
e2168c922e
feat(maps): persist grid and radar overlay toggles across navigation 2026-04-20 14:53:17 -05:00
fc7481dca2
feat(weather-map): persist selected layer across navigation 2026-04-20 14:49:54 -05:00
FluxCD
7244a59a21 chore: update prop image to git.mcintire.me/graham/prop:main-1776712929-ddedd24 [skip ci] 2026-04-20 19:25:01 +00: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
FluxCD
444de00ef7 chore: update prop image to git.mcintire.me/graham/prop:main-1776712357-a7bea4a [skip ci] 2026-04-20 19:14:53 +00:00
a7bea4a7f2
feat(status): show radar enrichment progress alongside HRRR/Weather/Terrain/IEMRE 2026-04-20 14:12:22 -05:00
FluxCD
26640b1204 chore: update prop image to git.mcintire.me/graham/prop:main-1776711181-608fd32 [skip ci] 2026-04-20 18:55:45 +00:00
608fd32e5e
fix(prune): widen profile/score file cutoff from 2h to 3h
HRRR publishes ~2h after the cycle hour, so the hourly seeder uses
run_time = now - 2h. That puts the f00 analysis file's valid_time
right at the 2h prune cutoff — the file was being deleted within
minutes of the Rust analysis step writing it, leaving /weather and
/map point-detail with no data.

A 3h cutoff keeps the current analysis alive until the next hourly
run supersedes it; forecast files (valid_time >> cutoff) are
unaffected.
2026-04-20 13:52:48 -05:00
FluxCD
0c1d90ef58 chore: update prop image to git.mcintire.me/graham/prop:main-1776710724-f3fab30 [skip ci] 2026-04-20 18:47:39 +00:00
f3fab3092c
feat(status): show hrrr-point-rs queue in Rust workers panel
Second row in the Rust worker table pulls from hrrr_fetch_tasks so
per-QSO HRRR backfill progress is visible alongside prop-grid-rs.
Same columns (Running / Queued / Retrying / Done 1h), 2s cache, same
refresh debounce as the grid_tasks panel.
2026-04-20 13:45:11 -05:00
FluxCD
818b270a0a chore: update prop image to git.mcintire.me/graham/prop:main-1776710473-1a1ba46 [skip ci] 2026-04-20 18:44:39 +00:00
1a1ba460e4
fix(otel): drop opentelemetry_ecto + disable OTLP on Elixir pods
Ecto auto-instrumentation created one span per query, so Oban jobs
running thousands of DB ops produced traces with 8k+ child spans.
Tempo's compactor couldn't ingest them — it stalled long enough to
miss heartbeats, got auto-forgotten from the ring, and crash-looped.
The Elixir grpcbox exporter then blew up trying to reconnect.

Stops the cascade: OTLP endpoint removed from prop and prop-backfill
Deployments, opentelemetry_ecto removed from deps, Phoenix/Oban/Bandit
auto-instrumentation retained. Rust workers still export to Tempo.
2026-04-20 13:41:01 -05:00
FluxCD
5f4c02fb46 chore: update prop image to git.mcintire.me/graham/prop-grid-rs:main-1776709289-ed2563f [skip ci] 2026-04-20 18:39:39 +00:00
FluxCD
67c6d74fcf chore: update prop image to git.mcintire.me/graham/prop:main-1776709289-ed2563f [skip ci] 2026-04-20 18:34:26 +00:00
ed2563f4f8
style(rust): apply cargo fmt across prop_grid_rs 2026-04-20 13:20:22 -05:00
efa3cc804d
perf(rust): disk-backed HRRR idx cache + scale workers off talos5
Shared NFS idx cache at /data/hrrr_idx lets all prop-grid-rs and
hrrr-point-rs replicas deduplicate redundant NOAA S3 idx fetches.
Opt-in via HRRR_IDX_CACHE_DIR; falls back to in-memory-only when unset.

Also scales prop-grid-rs to 3 replicas (no talos5 pinning),
hrrr-point-rs to 2 replicas, and drops hot pod replicas 4→3 so the
physical-host anti-affinity still fits with talos5 cordoned off for
Postgres.
2026-04-20 13:18:38 -05:00
FluxCD
e2a2ad42fd chore: update prop image to git.mcintire.me/graham/prop:main-1776705188-b6b5945 [skip ci] 2026-04-20 17:17:04 +00:00