Harden Rust grid worker: NOTIFY source, temp leaks, partial downloads #25
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/rust-worker"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Audit Rust crate group.
Verified: CFLAGS=-O2 cargo clippy --all-targets -- -D warnings clean; cargo test --release 265 pass.
🤖 Skippy PR review
1 finding — 1 blocking before merge.
rust/prop_grid_rs/src/db.rs:274Reviewed
0e9b801150f2. Commentskippy reviewto re-run.@ -269,3 +272,3 @@let payload = format!("{}|{}","{}|{}|{}",🔴 Critical — Third payload field breaks the listener on main if this lands first
The new
run|valid|sourcepayload is only safe once the Elixir side knows about it. At this headlib/microwaveprop/propagation/notify_listener.ex:71still doesString.split(payload, "|", parts: 2), so"<run>|<valid>|hrrr"splits to["<run>", "<valid>|hrrr"]; the pattern matches,DateTime.from_iso8601("<valid>|hrrr")returns:error, and thewithelse branch only logsmalformed propagation_ready payload. Net effect on the Rust-first merge order:handle_propagation_ready/2never runs, so noScoreCache.broadcast_invalidate/1, noretain_scores_window/1(score files on NFS stop being pruned), no scalar materialization, no PubSub broadcast — Rust reports every taskdonewhile/mapsilently keeps serving the last pre-deploy cycle.The PR says this is "safe in either merge order"; that holds in the Elixir-first order only (
fix/propagation-pipelineparses[run_time_str, valid_time_str | rest]and defaults source to"hrrr", so it accepts both shapes) — the Rust-first order is the broken one. Landfix/propagation-pipelinefirst, or gate the new payload behind the listener change. Also stale on the old contract:docs/runbook_propagation_pipeline.md:21and the runbook table inAGENTS.mdboth still document'<run_time>|<valid_time>'.0e9b8011501bfe12b83e🤖 Skippy PR review
1 finding — 1 blocking before merge.
rust/prop_grid_rs/src/pipeline.rs:330Resolved 1 earlier finding: the 3-field payload is safe now that main's NotifyListener parses [run_time, valid_time | rest] and treats a missing source as nil (no retain sweep). Still stale on the old 2-field contract: docs/runbook_propagation_pipeline.md:21 and the AGENTS.md payload line. Branch was force-pushed, so this pass reviewed the full PR diff.
Reviewed
1bfe12b83ef9. Commentskippy reviewto re-run.@ -311,3 +329,2 @@let path = tmp_dir.join(format!("hrdps_native_{nanos}_{pid}.grib2"));std::fs::write(&path, &blob_for_decode)?;pathstd::fs::write(&path, &blob)?;🟡 Warning — TempGrib guard is built after the write, so a failed write still leaks the temp file
std::fs::write(&path, &blob)?runs beforeTempGrib(path)exists, andfs::writecreates the file before it writes it: if the ~100 MB write dies partway (ENOSPC on the pod's/tmp), the?returns with no guard in scope and the partial file is stranded. That is the exact leak class this change fixes, and the stranded bytes make the next HRDPS write more likely to fail the same way.Construct the guard first, then write through it:
(
decoder::extract_gridhandles the same shape correctly by removing both temp paths after the closure regardless of its result.)1bfe12b83e945209fc42Addressed in
945209fc— the critical finding is stale by merge order:fix/propagation-pipeline(#18) merged first, and main'sNotifyListeneralready parses[run_time_str, valid_time_str | rest]with a missing source defaulting to"hrrr"(notify_listener.ex:89-99), so the 3-field payload is safe on current main. The genuinely stale part —docs/runbook_propagation_pipeline.mdandAGENTS.mddocumenting the 2-field contract — is now updated to describe the optional third field.🤖 Skippy PR review
2 findings — none blocking.
AGENTS.md:146hrrr; the listener on main does the oppositerust/prop_grid_rs/src/db.rs:277Resolved 0 of 1 earlier finding: pipeline.rs:330 still runs std::fs::write(&path, &blob)? before TempGrib(path) exists, so a mid-write failure still strands the temp file (that thread stays open). Branch was force-pushed, so this pass reviewed the full PR diff.
Reviewed
945209fc4245. Commentskippy reviewto re-run.@ -144,3 +144,3 @@- `GridTaskEnqueuer` inserts are idempotent via the `(run_time, forecast_hour, kind, source)` unique index. Re-seeding a cycle is a no-op, which is why the crons fire multiple times per hour.- **Elixir has no fallback compute path.** If Rust is dead, `grid_tasks` backs up and `/map` serves the last good run. See `docs/runbook_propagation_pipeline.md` for the full failure-mode table (FM1–FM4).- The NOTIFY payload is `"<run_time_iso>|<valid_time_iso>"` — pipe-delimited, run_time first, because `retain_scores_window/1` anchors retention on run_time.- The NOTIFY payload is `"<run_time_iso>|<valid_time_iso>|<source>"` — pipe-delimited, run_time first, because `retain_scores_window/1` anchors retention on run_time. The trailing source (`hrrr`/`hrdps`/`gefs`) is optional on the wire; a missing field is treated as `hrrr` for mixed-version deploys.🔵 Suggestion — Doc says a missing source means
hrrr; the listener on main does the oppositeThis line now reads "a missing field is treated as
hrrrfor mixed-version deploys", but main's listener does the reverse. Inlib/microwaveprop/propagation/notify_listener.ex(as merged in #18 on 2026-09-22)sourcebecomesnilwhen the third field is absent or empty, andkickoff_post_notify_work/3only callsPropagation.retain_scores_window/1whensource == "hrrr"; its moduledoc states that keeping a missing source as the skip case is the fail-safe direction, because guessinghrrrwould let an HRDPS run_time (4-10 h stale) anchor the 48 h window and trim live HRRR f39-f48 files.docs/runbook_propagation_pipeline.md:24-26repeats the same claim. Either drop the fallback sentence or write it as "a missing source skips the sweep", otherwise the next agent that implements this file's contract reintroduces exactly the deletion hazard the gate was added to prevent.@ -272,2 +275,3 @@task.run_time.format("%Y-%m-%dT%H:%M:%SZ"),task.valid_time.format("%Y-%m-%dT%H:%M:%SZ")task.valid_time.format("%Y-%m-%dT%H:%M:%SZ"),task.source.as_str()🔵 Suggestion — This is the change that switches the on-disk retention sweep back on
Adding
task.source.as_str()here is not inert on main:NotifyListenergatesPropagation.retain_scores_window/1on an explicithrrr, so while Rust sent two fields (every Rust build merged to date, including #18 this morning) that sweep never ran. After this merges, every completed HRRR task fires it (~29 per cycle) and it walks three NFS trees, deleting profiles/scalars outside[run_time, run_time+48h]and score files outside 168 h. That matches the intended design, but it is a prod behaviour flip that neither the description nor the CHANGELOG mentions, and the description's "safe in either merge order" only covers payload parsing. Add a line to the description/CHANGELOG so the first post-deploy cycle gets watched instead of assumed, or gate the new field until then.