Commit graph

1189 commits

Author SHA1 Message Date
FluxCD
54bc46ab60 chore: update prop image to git.mcintire.me/graham/prop:main-1776635090-6d91461 [skip ci] 2026-04-19 21:47:29 +00:00
FluxCD
76a407c8b1 chore: update prop image to git.mcintire.me/graham/prop-grid-rs:main-1776635096-6d91461 [skip ci] 2026-04-19 21:46:29 +00:00
6d914614ac
perf(grid-rs): stream bands to disk, drop merged grid early
Previous pipeline held three overlapping large structures in RAM:
1. `merged: HashMap<Key, HashMap<String, f32>>` — 92k outer × ~60 String
   keys inner = ~200 MB of fragmented heap
2. `per_band: HashMap<u32, Vec<ScorePoint>>` — all 23 bands × 92k
   ScorePoints before the first write
3. A `scores.clone()` per band during the write loop (doubled that
   band's footprint transiently)

Consolidate into:
- Consume `merged` into a compact `Vec<(lat, lon, Conditions,
  BandInvariants)>`; the huge String-keyed HashMaps drop as soon as
  collection finishes.
- Loop over bands: score → write → vector drops at end of iteration.
  One band's ~1.8 MB score vector lives at a time.

Peak heap goes from ~1.5 Gi to ~250 Mi on the chain step. 2 Gi limit
stays, but we now have 8× headroom instead of tight-fit.
2026-04-19 16:44:35 -05:00
FluxCD
ae4b0742fb chore: update prop image to git.mcintire.me/graham/prop:main-1776634649-ca33b83 [skip ci] 2026-04-19 21:41:27 +00:00
ca33b8331b
fix(grid-rs): bump memory limit to 2 Gi
1 Gi OOM'd during a real chain step. Each forecast hour loads both a
surface and pressure-level HRRR GRIB2 (~40 MB combined), decodes via
wgrib2 subprocess (which allocates its own buffers), scores 92k grid
points × 23 bands (~2.1M intermediates), and writes 23 score files.
2 Gi absorbs the spike; still ~3× smaller than the Elixir-per-pod
budget we're replacing.
2026-04-19 16:37:05 -05:00
25c3d4f688
fix(grid-rs): bump memory limit to 1 Gi
The real worker binary OOM-killed at 512 Mi on startup. The plan's
512 Mi assumption was speculative — actual Rust RSS at boot includes
sqlx pool buffers, reqwest connection pool, tokio runtime, and
tracing JSON subscriber. 1 Gi leaves room for all of that plus the
steady-state ~500 Mi during a chain step.
2026-04-19 16:35:18 -05:00
FluxCD
90281b8562 chore: update prop image to git.mcintire.me/graham/prop-grid-rs:main-1776634333-2d67cf7 [skip ci] 2026-04-19 21:32:58 +00:00
FluxCD
e7496331ba chore: update prop image to git.mcintire.me/graham/prop:main-1776634019-2d67cf7 [skip ci] 2026-04-19 21:31:30 +00:00
2d67cf7611
fix(grid-rs): decode run_time / valid_time as NaiveDateTime
Elixir's Ecto :utc_datetime maps to Postgres TIMESTAMP WITHOUT TIME
ZONE — Ecto's contract is that the value is UTC, but the column
itself is naive. sqlx's DateTime<Utc> binds/reads as TIMESTAMPTZ, so
the read failed with "mismatched types ... TIMESTAMPTZ is not
compatible with SQL type TIMESTAMP".

Read as NaiveDateTime and reattach UTC at the decode boundary, keeping
the downstream DateTime<Utc> signature. Test bind goes the other
direction — bind .naive_utc() against the TIMESTAMP column.
2026-04-19 16:26:43 -05:00
FluxCD
a40e2d1630 chore: update prop image to git.mcintire.me/graham/prop-grid-rs:main-1776633497-5692d50 [skip ci] 2026-04-19 21:25:02 +00:00
735553e443
feat(flux): add image-automation for prop-grid-rs
Mirrors the Elixir wiring:
- ImageRepository + ImagePolicy scan git.mcintire.me/graham/prop-grid-rs
  for main-<ts>-<sha> tags and pick the highest timestamp.
- ImageUpdateAutomation already covers ./k8s via the Setters strategy,
  so the new setter marker on deployment-grid-rs.yaml means flux will
  rewrite it on every CI push and commit the update back to main.
2026-04-19 16:24:33 -05:00
FluxCD
bf370ba1e4 chore: update prop image to git.mcintire.me/graham/prop:main-1776633273-03ba48f [skip ci] 2026-04-19 21:18:02 +00:00
03ba48fe36
chore(prop): stop seeding grid_tasks until Rust is validated
Option A from the shadow-mode duplication discussion — Elixir still
owns f00-f18 fan-out via Oban and writes to /data/scores. The Rust
worker sits idle polling an empty grid_tasks table instead of
re-fetching the same HRRR GRIB2 and writing a parallel tree to
/data/scores_shadow.

Brings the seed call back when Phase 2 cutover is approved (at which
point the Elixir fan-out shrinks to fh=0 and PROP_SCORES_DIR flips
from /data/scores_shadow to /data/scores).
2026-04-19 16:14:17 -05:00
5692d50a72
fix(grid-rs): build the actual binary, not the stub
Previous Dockerfile compiled a 'fn main(){}' placeholder to warm the
dep cache, then was supposed to rebuild with real source — but cargo's
fingerprint wasn't picking up the re-added source after rm -rf, so the
placeholder artefact (~430 KB) was what got COPY'd into runtime.

Replace the stub-then-real dance with a single cache-mounted build.
BuildKit persists /usr/local/cargo/registry + git + /src/target across
runs, so dep changes are the only full-rebuild trigger. Copy the real
binary into /usr/local/bin/worker before the COPY --from= in runtime
because COPY --from= can't read from a cache mount.
2026-04-19 16:12:42 -05:00
FluxCD
04df743e82 chore: update prop image to git.mcintire.me/graham/prop:main-1776632590-6e64e08 [skip ci] 2026-04-19 21:07:57 +00:00
6e64e0850b
fix(grid-rs): use :latest tag for wgrib2 source image
The Elixir build workflow only pushes :${TAG} (timestamped) and :latest
— there is no :main tag. Switch to :latest to match what's actually
published.
2026-04-19 16:02:52 -05:00
FluxCD
862c0a915e chore: update prop image to git.mcintire.me/graham/prop:main-1776632295-79bbdf9 [skip ci] 2026-04-19 21:01:53 +00:00
FluxCD
8d3abbc711 chore: update prop image to git.mcintire.me/graham/prop:main-1776632143-8774181 [skip ci] 2026-04-19 20:58:43 +00:00
79bbdf900b
fix(grid-rs): pull wgrib2 from the Elixir image
wgrib2 isn't in Debian apt on trixie — the main prop Dockerfile builds
it from source in a dedicated stage. Rather than duplicate that 5-min
compile in the Rust image's CI path, copy the already-built
/usr/local/bin/wgrib2 + /usr/local/lib/libg2c.so* out of the published
Elixir image.

Also add the libgfortran5/libaec0/zlib1g/libpng16-16t64/libopenjp2-7
runtime deps that wgrib2 links dynamically against — same set the
Elixir runtime installs.
2026-04-19 15:57:57 -05:00
8774181824
ci: run clippy + cargo test inside Dockerfile builder
The previous approach (`docker run -v $(pwd)/rust/prop_grid_rs:/src`)
fails under act_runner because the runner container's pwd isn't a path
the host docker daemon can see — clippy installed, then cargo couldn't
find Cargo.toml.

Move the lint + test gate into the Dockerfile builder stage so the
build context ships over the socket the normal way. If clippy warns or
a test regresses, the image build fails and nothing is pushed.
2026-04-19 15:55:26 -05:00
FluxCD
9a944f40e5 chore: update prop image to git.mcintire.me/graham/prop:main-1776631843-339591c [skip ci] 2026-04-19 20:53:39 +00:00
339591cedd
ci: install clippy component before running it
`rust:1.94-trixie` ships rustc + cargo but not clippy — `rustup
component add clippy` pulls it in on the fly.
2026-04-19 15:50:19 -05:00
d570452168
ci: add build-grid-rs workflow for the Rust worker image
Runs `cargo clippy --all-targets -- -D warnings` + `cargo test` inside
a rust:1.94-trixie container, then builds `rust/prop_grid_rs/Dockerfile`
and pushes three tags: the timestamped `main-<ts>-<sha>`, `latest`, and
a static `main` tag that `k8s/deployment-grid-rs.yaml` pins to.

Path-filtered to `rust/prop_grid_rs/**` so Elixir-only pushes don't
trigger a redundant Rust build.
2026-04-19 15:48:41 -05:00
FluxCD
f0485dd595 chore: update prop image to git.mcintire.me/graham/prop:main-1776631383-b808780 [skip ci] 2026-04-19 20:46:29 +00:00
b80878056d
feat(grid-rs): Rust worker for HRRR f01..f18 propagation chain
Extracts the memory-hostile HRRR fetch → decode → score pipeline for
forecast hours 1–18 into a separate Rust service (`prop-grid-rs`).
Elixir retains f00 with its native-duct + NEXRAD + commercial-link
enrichment; Rust handles the other 18 steps per hourly chain.

Hand-off via a new `grid_tasks` table (`FOR UPDATE SKIP LOCKED` claim
from Rust, Postgrex `NOTIFY propagation_ready` back to Elixir). Rust
writes the same on-disk score-grid format Elixir already uses, to the
same `/data/scores` NFS tree. Phase 1 ships in shadow mode with
PROP_SCORES_DIR=/data/scores_shadow.

Rust crate layout at `rust/prop_grid_rs/` (1:1 module parity with the
Elixir source it ports):
  - grid, region, band_config, scores_file, sounding_params
  - scorer: all 10 factors + composite. Matches the Elixir scorer
    byte-for-byte across 115 golden-fixture samples (5 scenarios
    × 23 bands).
  - decoder: wgrib2 subprocess + Fortran-record lola-binary parser
  - fetcher: HRRR URL derivation + idx cache (1h TTL) + 8-way
    parallel byte-range downloads with 429/5xx retry
  - pipeline: end-to-end chain step, f00 rejected at the boundary
  - db: sqlx grid_tasks claim/complete + propagation_ready NOTIFY
  - bin/worker: tokio main loop, JSON logs, SIGTERM-safe

91 Rust tests + clippy -D warnings clean. 2,158 Elixir tests green.

Elixir additions:
  - `GridTaskEnqueuer` seeds fh=1..18 rows from
    `PropagationGridWorker.seed_chain/0`
  - `NotifyListener` LISTEN → warm `ScoreCache` → PubSub fan-out
  - `ShadowComparator` diffs prod vs shadow `.ntms` bodies
  - `mix rust.golden` task writes the Rust-side golden fixture

k8s: new `deployment-grid-rs.yaml` pinned to talos5 (32 GB NUC) via
`prop-grid-rs=primary` nodeSelector + `workload=grid-rs:NoSchedule`
toleration, 512 Mi limit, sharing the existing NFS `/data` mount.

The plan document is at `plans/vivid-hatching-quail.md` (local to my
workstation); phases 2 (cutover) and 3 (talos5 concurrency tuning)
follow after 72h of shadow-mode parity.
2026-04-19 15:42:49 -05:00
cdfbe85485
fix(oban): use paused queues instead of local_limit:0 on backfill pods
Oban Pro 2.21.1's Smart engine rejects `local_limit: 0` with
"local_limit must be greater than 0", OOMing the backfill pod on every
boot since the upgrade. Switch the disable pattern to
`[local_limit: 1, paused: true]` so the queue still exists (satisfying
the validator) but processes nothing. Verified under PROP_ROLE=backfill
the four hot queues (propagation, commercial, solar, enqueue) resolve
to paused=true and the others keep their normal concurrency.
2026-04-19 15:42:49 -05:00
FluxCD
fd95a82478 chore: update prop image to git.mcintire.me/graham/prop:main-1776628623-f1846c0 [skip ci] 2026-04-19 20:00:04 +00:00
f1846c0a53
perf: reduce per-pod RSS and HRRR chain wall time
Telemetry showed the application-master process holding ~830 MiB of
terms from warm_grid_cache_from_latest_profile — the data lives in
the app master's heap and never GCs because the process is idle.
Running it in a Task.start lets the terms die with the task.

Mark GridCache, MrmsCache, NexradCache, and ScoreCache ETS tables
:compressed. The scored-band-map and HRRR grid data are map-heavy;
compression trims hundreds of MiB at a few percent CPU cost.

Memoise HRRR .idx responses in Microwaveprop.Cache. Published idx
files are immutable for a model run, but the hourly chain re-fetches
the same URL dozens of times across forecast hours. Cuts ~10s per
repeat out of hrrr_fetch_idx.

Force a garbage collect at the end of HrrrFetchWorker.perform to
reclaim the refc binary heap held from GRIB2 ranges before the Oban
producer hands the process its next job.
2026-04-19 14:56:48 -05:00
dfc7895197
chore(infra): bump prop to 4 replicas for talos5 (NUC)
talos5 is a NUC7i5 with 32GB RAM and a Samsung 980 Pro NVMe. Adding
a 4th hot-path replica lets the anti-affinity rule place one pod
per physical host across node1/2/3/5 and gives the hourly f00-f18
grid chain 4-way parallel fan-out, with the NVMe-backed pod
handling its share of grib extraction.
2026-04-19 14:37:52 -05:00
fb5b1f06f8
fix(infra): actually drop hot queues + cron on backfill pods
config.exs registers Oban queues + the Cron plugin at compile time,
and Config deep-merges with runtime.exs by key. Simply omitting
propagation/commercial/solar from runtime.exs left them inheriting
from config.exs, so backfill pods were still processing hot-path
queues and holding the Cron plugin. Override the hot queues to
limit: 0 and re-register Cron with an empty crontab in backfill
mode so the compile-time defaults get neutralised.
2026-04-19 14:36:12 -05:00
FluxCD
3be5480af6 chore: update prop image to git.mcintire.me/graham/prop:main-1776626172-1b2424a [skip ci] 2026-04-19 19:19:50 +00:00
1b2424a1c1
feat(infra): add prop-backfill deployment for T620 nodes
PROP_ROLE=backfill drops the hot-path propagation/commercial/solar
queues and the Cron plugin. Paired with a dedicated Deployment
(nodeSelector: prop-backfill=true, 4Gi memory, no web) this lets 8GB
T620 workers drain the hrrr/weather/terrain/iemre/narr backfill
queues without competing with the hourly grid chain.
2026-04-19 14:14:28 -05:00
23e4e3fef2
perf(weather): token-bucket IEM limiter + parallel HRRR surface/pressure
Two independent wins the latest prod telemetry pointed at:

1. Iowa Environmental Mesonet rate limiter. IEM throttles per source
   IP across all in-flight requests, so dropping the `:weather` Oban
   queue to 1/pod wasn't enough — workers were still issuing back-to-
   back requests inside each job and drawing a steady stream of HTTP
   429s (1,396 retryable on the queue). A GenServer-based token bucket
   serialises acquire() with a 700ms min gap per pod; three pods give
   ~4 req/sec cluster-wide, well under the observed 429 threshold.
   Wrapped around every IemClient fetch.

2. Parallel HRRR surface + pressure grid fetch. The two products live
   in separate wrfsfcf / wrfprsf GRIB files, so their fetch → wgrib2
   → decode pipelines are independent; running them sequentially was
   adding ~30s per forecast hour for no reason. Task.async the pair,
   merge at the end. Halves per-fh wall time and should unblock some
   of the missing-hour cases we saw on the 14:00Z chain.
2026-04-19 12:57:30 -05:00
FluxCD
bc153055c6 chore: update prop image to git.mcintire.me/graham/prop:main-1776620433-6f63f3b [skip ci] 2026-04-19 17:43:07 +00:00
6f63f3bb5e
feat(admin): surface flagged contacts on the contact-edits page
Flagged contacts were only reachable from the contact detail page;
once the Flag column came off the /contacts index there was no
at-a-glance view of what's been flagged. Add a second table on the
admin contact-edits page (below the pending-edits table) that lists
every flagged contact newest-first with a direct View link. Hidden
entirely when nothing is flagged.
2026-04-19 12:40:20 -05:00
FluxCD
0942d0bbfb chore: update prop image to git.mcintire.me/graham/prop:main-1776620227-a6a3d3c [skip ci] 2026-04-19 17:40:04 +00:00
a6a3d3ced3
chore(contacts): drop the Flag column from the table
The `!` flag column was a tiny sliver that rarely fired in practice
and was pushing more useful columns offscreen on narrow layouts. The
underlying `flagged_invalid` field stays on the schema for the detail
page; only the index column goes.
2026-04-19 12:36:52 -05:00
4b155c5419
feat(contacts): hide Private column when the viewer has nothing private
Row-level privacy was already correct: private rows only render for
the owner and admins. The header column was still showing for every
viewer, leaving an always-empty slot for anyone without private
contacts of their own. Drop `:private` from the fields list unless
the scope can actually see at least one private row.
2026-04-19 12:36:12 -05:00
FluxCD
c38f4e2bfe chore: update prop image to git.mcintire.me/graham/prop:main-1776619974-9db9e6a [skip ci] 2026-04-19 17:35:46 +00:00
9db9e6a893
fix(map): anchor timeline offsets to the Now slot, not wall-clock
When wall-clock is past :30, the hour *after* Now rounded to "0h" and
the Now slot kept its "Now" tag — two adjacent buttons both reading
as the current hour. Anchor the offset math to the Now slot itself so
the label sequence reads "-1h / Now / +1h / +2h" regardless of where
inside the hour the user loaded the page.
2026-04-19 12:32:41 -05:00
fd48263992
Revert "ci: build against a dind sidecar instead of the host socket"
This reverts commit 2a6f14129a.
2026-04-19 12:29:24 -05:00
2a6f14129a
ci: build against a dind sidecar instead of the host socket
The host docker socket wasn't mounted consistently across act_runners,
so which runner picked up the push was the difference between a clean
build and "unable to connect to docker." Switch to a dockerd service
pinned to the same bridge network as the job; the CLI now talks to
it over TCP via DOCKER_HOST=tcp://dockerd:2375. Every runner has a
daemon, every time — no more re-runs for the scheduling lottery.
2026-04-19 12:24:41 -05:00
c5436279ed
ci: dump docker socket diagnostics when the daemon poll times out
The 30s poll is enough to ride out a slow daemon startup, but not a
runner where the socket isn't mounted at all. Print DOCKER_HOST, the
socket stat, and the verbose `docker info` error on timeout so the
next flaky run tells us whether it's a startup race or a missing
mount.
2026-04-19 12:21:39 -05:00
d51f545ab7
ci: ride out docker daemon + registry flakes
Forgejo act_runner mounts the host docker socket into the job
container; the daemon sometimes takes a beat to answer, which has
been surfacing as intermittent "unable to connect to docker" failures
on login. Gate the build on a `docker info` poll (up to 30s) so later
steps don't race the socket, and wrap login + buildx push in a 3-try
exponential-backoff (2s/4s/8s) so a transient registry hiccup doesn't
require a manual workflow re-run.
2026-04-19 12:19:43 -05:00
FluxCD
cad4bfc6f5 chore: update prop image to git.mcintire.me/graham/prop:main-1776619000-ffb14cb [skip ci] 2026-04-19 17:19:36 +00:00
ffb14cb64f
feat(map): auto-advance cursor, full forecast timeline, panel spacing
Three UI fixes:

1. MapLive now ticks every 60s and, when the selected_time is still
   the "Now" slot, advances to the newer latest-past-or-now hour as
   the clock crosses into it. The URL is patched in place so shared
   links stay accurate without polluting history. Manually picking a
   specific past/future hour turns tracking off.

2. point_forecast/3 now reads its valid_times list straight from the
   on-disk .ntms files (same source the main-map timeline uses), then
   consults the cache per-hour for a fast score lookup. The old cache-
   or-store branch could leave the click-to-detail sparkline at 13
   hours while the bottom timeline showed 14+; both now line up.

3. Point-detail panel moves from bottom-14 → bottom-24 on desktop so
   its lower edge clears the forecast-timeline bar (+ max-height tuned
   to match), fixing the overlap visible in screenshots.
2026-04-19 12:10:36 -05:00
FluxCD
d3a1e93587 chore: update prop image to git.mcintire.me/graham/prop:main-1776618115-01b181b [skip ci] 2026-04-19 17:04:32 +00:00
01b181b1e8
perf(propagation): shrink hourly chain wall time
Four changes sized by measured prod telemetry (83m of spans):

1. propagation queue: 2 → 1 slot per pod. Two concurrent forecast-hour
   steps per pod stacked HRRR grid + native duct grid + scored band
   map into ~5-6 GiB RSS, OOM-killing every ~15 min. 3-way parallelism
   cluster-wide still finishes the chain inside the hourly interval.

2. weather queue: 3 → 1 slot per pod. ASOS backfill was 429-thrashing
   IEM (1,296 retryable jobs; logs were nothing but 429 backoffs).

3. PropagationGridWorker: skip native-level duct fetch on f01..f18.
   At ~7-11 min/fh and 18 forecast hours, this was the largest single
   cost per chain. Forecast hours fall back to
   derived[:min_refractivity_gradient] from the pressure-level
   profile. f00 still gets full native-level duct analysis.

4. HrrrClient.download_grib_ranges_to_file: parallelize with
   Task.async_stream (max_concurrency 8). The file-backed variant was
   sequential, dominating native-duct fetch time on the remaining f00
   path. ~20s → ~3s per call.
2026-04-19 12:01:41 -05:00
FluxCD
95a769bf0e chore: update prop image to git.mcintire.me/graham/prop:main-1776612418-0d02239 [skip ci] 2026-04-19 15:30:08 +00:00
0d022392fe
fix(propagation): use map_size on grid_data telemetry count
The :score_band span was reading point_count via length(grid_data),
but grid_data is a %{{lat, lon} => profile} map. Every forecast-hour
job was crashing with ArgumentError before any scores landed, leaving
the map without current-hour forecasts. Swap to map_size/1.
2026-04-19 10:26:46 -05:00