Pods were OOMKilled at :05 every hour at the 3 GiB cgroup limit.
Idle RSS climbed 750 MiB → 1.5 GiB across pod lifetime as the
hourly f00 analysis cycled surface + pressure + native-duct blobs
(50 MB / 400 MB / 530 MB) simultaneously during decode. glibc
malloc retains dirty pages after freeing those large transient
allocations, so RSS drifted upward with each cycle and the next
hourly peak eventually crossed the limit.
jemalloc's background decay returns pages to the kernel on a
short schedule, so RSS tracks the actual working set. Wired in
at lib.rs so both `worker` and `hrrr_point_worker` inherit it.
Skipped on MSVC targets (Windows CI if we ever add one); dev
builds on macOS still pick it up via the generic target_env
gate.
The forgejo-runner was offline when 989d310..410a137 landed, so the
build-grid-rs workflow never dispatched for those pushes. Empty CI
retrigger commits don't match the path filter. Touching the crate
root forces build-grid-rs.yaml to fire and pick up the HRRR S3
fallback + scores_file .prop rename + point lookup fix.
Also corrects the now-stale "f01..f18 forecast hours only" note — as
of Phase 3 Stream A, Rust owns the f00 analysis step too.
New telemetry module wires tracing-subscriber to both a local JSON
fmt layer (keeps kubectl-logs output identical) and an
OpenTelemetry OTLP/gRPC exporter, activated only when
OTEL_EXPORTER_OTLP_ENDPOINT is set. The returned TelemetryGuard
holds the SdkTracerProvider until process shutdown so queued spans
flush before exit.
Both bin targets (worker, hrrr_point_worker) now call
telemetry::init(service_name) at startup; service_name becomes the
OTel service.name attribute so Tempo groups spans per binary.
Tracing instrumentation on the three main work units:
- pipeline::run_chain_step (forecast f01..f18)
- pipeline::run_analysis_step (analysis f00)
- hrrr_points::process_batch (per-QSO point drain)
k8s manifests set OTEL_EXPORTER_OTLP_ENDPOINT to the cluster
collector at otel-collector.observability.svc.cluster.local:4317.
Backend wiring lives in the vntx-infra repo.
Phase 3 Stream C Rust side. Completes the HrrrFetchWorker port.
Pipeline:
- db::claim_next_hrrr_task — FOR UPDATE SKIP LOCKED on hrrr_fetch_tasks,
newest valid_time first. Accepts the points JSONB directly.
- hrrr_points::process_batch — fetch surface + pressure GRIB2 once
per task (tokio::try_join), decode via the existing wgrib2 plumbing,
then for each requested point pull the cell and UPSERT INTO
hrrr_profiles (conflict on lat/lon/valid_time).
- db::complete_hrrr_task / fail_hrrr_task — status transitions; Elixir
backfill re-enqueues failed rows on next /30-min scan.
Shipping pieces:
- new bin src/bin/hrrr_point_worker.rs
- new module src/hrrr_points.rs (process_batch, upsert_profile)
- new Cargo [[bin]] entry; Dockerfile builds both binaries in one stage
and ships them in the runtime image so a single CI pipeline covers
the whole cluster
- k8s/deployment-hrrr-point-rs.yaml (1 replica, 1 Gi limit, anti-affinity
against prop-grid-rs so chain + point work don't fight for wgrib2
slots). Uses the same image; command: override picks the right binary.
- kustomization.yaml: include the new deployment so flux applies it
- deployment-grid-rs.yaml: bump readiness initialDelaySeconds 3→15 +
failureThreshold 3→6 so a slow DB connect during startup can't race
the first probe
119 Rust tests green.
Rust-side writer for the f00 ProfilesFile. Replaces the Elixir ETF
format (:erlang.term_to_binary) with MessagePack + gzip so Rust can
produce files without porting the Erlang serializer.
Wire layout: top-level map with 'v' (format version), 'valid_time'
(ISO string), and 'cells' (array of {lat, lon, profile}). Profile
values are an rmpv::Value so the writer stays agnostic about what
fields the f00 pipeline decides to include — native-duct metrics,
NEXRAD reflectivity, commercial-link degradation, and the sounding
profile list all serialize through the same path.
All keys are strings — MessagePack has no atom type. Elixir's reader
(follow-up commit) converts known keys to atoms via a whitelist.
Atomic writes via rename(2) mirror ScoresFile/ProfilesFile pattern:
write to tmp sibling, rename into place on the shared NFS mount.
4 unit tests cover round-trip, atomic rename, path layout, and
lat/lon snap.
native_duct.rs is the Rust side of HrrrNativeClient.fetch_native_duct_grid:
fetch native-level HRRR idx → select duct byte ranges (TMP/SPFH/HGT/PRES
× 50 hybrid levels = 200 messages, ~300 MB subset) → wgrib2 decode via
the existing decoder::extract_grid plumbing → per-cell build_native_profile
→ duct::analyze → HashMap<(lat, lon), DuctMetrics>.
build_native_profile keeps the Elixir semantics intact: drop cells that
don't reach 3 levels, sort by ascending height, tolerate missing SPFH/PRES
as zero (the duct math handles zeros gracefully). merge_duct_grid gives
the pipeline.rs caller a drop-in replacement for Elixir's apply_duct_grid.
claim_next_analysis is the analysis-lane companion to claim_next: selects
kind='analysis' rows only, same SKIP LOCKED + newest-first ordering. Kept
on a separate claim function so the forecast lane can keep running
unaffected during cutover; the analysis pipeline wiring lands next.
9 pure unit tests cover message generation, URL builder, profile-sort,
min-level filter, and merge-into-base behaviour. Network-live golden
against a real native GRIB2 deferred to the cutover commit.
Port of the subset of Microwaveprop.Weather.NexradClient used by
f00's chain step: 5-min-rounded IEM n0q PNG fetch, palette-indexed
decode via the png crate, and per-POI max_reflectivity_dbz extraction
from a 25-pixel half-box (matches Elixir's DEFAULT_BOX_HALF).
The palette↔dBZ math and (lat, lon)↔pixel projection are bit-for-bit
copies of the Elixir formulas. 6 pure-math unit tests cover pixel
mapping, box clipping, and timestamp rounding. Network-live golden
fixture deferred until the full Stream A wiring lands.
Port of Microwaveprop.Commercial.build_link_lookup/2 +
link_degradation_from_lookup/3. The Elixir context stays intact
(LiveView sensor + SNMP poller); this Rust side is the read-only
consumer used by the f00 chain step (Phase 3 Stream A).
Three pure-math unit tests cover: haversine distance (DFW→Austin and
same-point zero), degradation_at aggregation behaviour, and
two-decimal rounding convention that Elixir's Float.round(_, 2)
imposes. SQL queries mirror the Elixir Ecto queries one-for-one —
baseline window, current point-in-time read, and MIN_BASELINE_SAMPLES
quality gate.
Pure port of lib/microwaveprop/propagation/duct.ex — the ITU-R P.834
duct detector that computes the modified refractivity M-profile and
finds regions where dM/dh < 0, then uses Bean & Dutton to derive the
minimum trapped frequency per duct.
Part of Stream A (f00 port): compute_duct_metrics and the per-cell
native-level duct merge both build on analyze(). Landing it now with
standalone unit tests lets later tasks (fetch_native_duct_grid, the
wgrib2 subprocess for native levels) treat it as a known-good
dependency instead of a concurrent port.
Three independent improvements in one commit:
1. Parallel band scoring (pipeline.rs):
- rayon par_iter across 23 bands replaces the serial for loop.
- All band files now write via try_join_all over spawn_blocking
instead of serializing one-at-a-time on NFS. Chain per-step
drops from ~60s to ~15-25s on a 4-core box.
2. Prometheus metrics (new metrics.rs):
- axum server on METRICS_ADDR (default :9100) exposes /metrics and
/health. Scraped by existing Prometheus via annotation.
- Histograms: chain step duration (by outcome), decode duration.
- Gauge: tasks in flight (RAII guarded so panics don't leak).
- Counter: chain steps by outcome.
- worker.rs records step duration and wraps each run in an
InFlightGuard.
3. HA + ordering fixes:
- deployment-grid-rs.yaml: replicas 1→2, required podAntiAffinity
spreads them across hosts, nodeAffinity prefers talos5 for one.
PROP_GRID_RS_PARALLELISM 4→3 per pod (6 concurrent across
replicas; smaller secondary-node footprint).
- Readiness probe on /health so rollouts wait for the runtime to
be alive.
- claim_next ordering: run_time ASC → DESC so the newest hourly
run drains before any stragglers from a broken prior run. Within
a run, forecast_hour ASC keeps nearest-first.
- grid_task_enqueuer sets kind="forecast" explicitly and updates
conflict_target to the new 3-column unique index introduced by
migration 20260419222624.
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.