prop/docs/runbook_propagation_pipeline.md
Graham McIntire 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

128 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Propagation pipeline runbook
The hourly propagation grid flows across two runtimes and a shared
NFS volume. This doc names every failure mode at that seam and says
how the system recovers.
```
prop-grid-rs (worker nodes) Postgres (talos5 — 10.0.15.30) prop pods (node1/2/3)
├─ fetch GRIB2 (NOMADS) ├─ grid_tasks (SKIP LOCKED) ├─ ScoreCache (ETS)
├─ decode ├─ LISTEN/NOTIFY channel ├─ NotifyListener
├─ score 22 bands × 92k │ "propagation_ready" ├─ ScoreCacheReconciler (60s)
├─ write /data/scores/.../.prop │ └─ PubSub → LiveView
└─ NOTIFY propagation_ready ────┘
```
## Primary refresh path (happy case)
1. Rust completes `{band, valid_time}` compute and writes the `.prop`
file via atomic rename. (Readers also accept legacy `.ntms` files
with `NTMS` magic during a rolling deploy.)
2. Rust emits `NOTIFY propagation_ready '<iso valid_time>'`.
3. `NotifyListener` on each Elixir pod receives it and calls
`Propagation.warm_cache_and_broadcast/2` which reads the file once
and PubSub-broadcasts the payload to every peer.
4. Each pod's `ScoreCache` receives the message and writes into its
local ETS table. `/map` clients are notified via the
`"propagation:updated"` topic.
Latency budget end-to-end: < 2s.
## Failure modes
### FM1 — NOTIFY dropped on reconnect
**Symptom.** `{band, valid_time}` files land on disk but ScoreCache
stays cold until the first lazy miss.
**Why.** Postgres queues LISTEN notifications per-connection and
discards any that are unread on disconnect. A DB restart, a network
blip, or the Postgrex.Notifications connection hitting its idle
timeout all drop whatever queued notifies were in flight.
**Recovery.** `ScoreCacheReconciler` sweeps `/data/scores` every 60s
per-pod and warms missing `{band, valid_time}` pairs from disk into
local ETS. Each pod reconciles independently no cluster messaging
required.
### FM2 — NotifyListener not running locally
**Symptom.** One pod is cold while peers are warm. `/map` load time
on that pod is ~150ms slower than average until the reconciler runs.
**Why.** The listener isn't currently part of the supervision tree
(see `lib/microwaveprop/application.ex`). When it is re-added, early
startup or Postgrex.Notifications boot failure can leave it
unsubscribed for several seconds while Rust writes land.
**Recovery.** Same as FM1 reconciler catches up within one tick.
### FM3 — NFS stale read racing Rust write
**Symptom.** `ScoresFile.read/2` returns `{:error, reason}` when
the reader opens the file exactly between Rust's `rename(2)` of the
`.tmp.<uniq>` file and the `fsync` landing on the NFS server.
**Why.** The rename is atomic on the same NFSv4 filesystem, but the
NFS client cache can briefly serve a stale directory listing or a
no-longer-existing path.
**Recovery.** Reconciler calls `ScoresFile.read/2` directly and
pattern-matches `{:error, _} -> :error`, logging at debug and
skipping the key. The next tick re-reads the file after NFS
consistency settles. Lazy-miss path
(`Propagation.scores_at_fetch/3`) has historically surfaced this as
a LiveView error the reconciler warms the cache before that miss
happens.
### FM4 — Cluster partition splits ScoreCache broadcast
**Symptom.** Pods on one side of a cluster partition have up-to-date
cache; pods on the other side don't, even though both can read NFS
and Postgres.
**Why.** `ScoreCache.broadcast_put/3` fans the payload out over
PubSub. A libcluster partition stops the message reaching partitioned
peers.
**Recovery.** Each pod's reconciler reads from NFS directly, so it
converges regardless of PubSub reachability. Partition does not
cause divergent scores only transient latency until the next
sweep.
### FM5 — Rust worker dead
**Symptom.** `grid_tasks` rows with `status = 'queued'` accumulate;
no new `.prop` files land; `/map` serves the last successful run's
scores.
**Why.** Deployment bug, Rust panic, OOM on a worker node, or
disk-full on `/data`.
**Recovery.** Manual. `kubectl -n prop logs deploy/prop-grid-rs` for
cause. The Elixir side is read-only against the grid it does not
regenerate scores from within the BEAM since the extraction in
`65693ed`. `lib/microwaveprop/workers/propagation_grid_worker.ex`
still exists as a seed worker: the hourly cron fires it with empty
args and it inserts 19 `grid_tasks` rows (f00 + f01..f18) for Rust
to drain. All fetch/decode/score helpers moved to
`rust/prop_grid_rs/src/pipeline.rs`; the Elixir module is retained
only as the cron entry point, not as a fallback compute path.
## Monitoring signals
- `avg_over_time(beam_memory_total_bytes[1h])` on prop pods steady
climb above 1 GiB means the cache isn't pruning (see
`ScoreCache.prune_older_than/1` in `NotifyListener`).
- Reconciler log line `"ScoreCacheReconciler: warmed N"` if N is
consistently non-zero every minute, NOTIFY is dropping and
NotifyListener wants investigation.
- `grid_tasks` queue depth in Prometheus should drain to 0 between
hourly cron ticks.
## Test coverage
- `test/microwaveprop/propagation/score_cache_reconciler_test.exs`
covers FM1 and FM3 (missing-file skip).
- Partition recovery (FM4) is untested in CI requires multi-node
cluster and libcluster orchestration.