Fix terrain recompute on edit, antenna heights, and ADIF/CSV import bugs #21
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/terrain-import"
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 terrain/radio/import group.
Verified: ~300 targeted tests pass; full suite green.
- Terrain profiles are recomputed after a contact edit (upsert now replaces; worker only skips when status is complete AND a row exists) - TerrainProfileWorker uses the contact's height1_ft/height2_ft instead of the fixed 10 ft default, and no longer marks :complete on a failed upsert - ADIF header stripping uses binary_part on the byte offset — a BOM or non-ASCII byte no longer drops the first QSO - Non-digit QSO_DATE/TIME_ON yields a row error instead of crashing the import preview - CSV commit path handles {:error, :constraint_error} and logs duplicates - Grid/band edits now also reset radar_status and mechanism_status - Contacts.source/1 classifies contest-log imports correctly🤖 Skippy PR review
3 findings — 2 blocking before merge.
lib/microwaveprop/workers/terrain_profile_worker.ex:35lib/microwaveprop/radio/contacts.ex:587lib/microwaveprop/workers/terrain_profile_worker.ex:29Reviewed
dfdddc6160b9. Commentskippy reviewto re-run.@ -569,0 +584,4 @@{:ok, Contact.t()}| {:error, Ecto.Changeset.t()}| {:error, :duplicate, Contact.t()}| {:error, :constraint_error}🟡 Warning — New :constraint_error return is unhandled by two of three callers
create_contact/2can return the bare{:error, :constraint_error}(from theEcto.ConstraintErrorrescue at line 651), and only the CSV import was taught about it.lib/microwaveprop_web/controllers/api/v1/contact_controller.ex:83only matches{:ok, _},{:error, %Ecto.Changeset{}}and{:error, :duplicate, _}, so this raisesCaseClauseErrorand returns 500 instead of 409;lib/microwaveprop_web/live/submit_live.ex:105binds it tochangesetand callsto_form(:constraint_error), which crashes the LiveView. It is reachable becausecontacts_dedup_idxkeys on the coalesced station/grid pair whilefind_duplicate_contact/1compares non-null grids, so the post-rescue re-check can come back nil. Add a clause for it both places (or convert:constraint_errorto a changeset error insidecreate_contact/2so callers only ever see the three original shapes).@ -21,3 +28,2 @@Microwaveprop.Instrument.span([:worker, :terrain_profile], %{contact_id: contact_id}, fn ->if Terrain.has_terrain_profile?(contact_id) do_ = Radio.set_enrichment_status!([contact_id], :terrain_status, :complete)contact = Radio.get_contact!(contact_id)🔵 Suggestion — get_contact!/1 turns a deleted contact into a 20-attempt poison job
This now runs on every attempt, including the skip path that used to return
:okfor a contact with an existing profile. A contact deleted while its terrain job is queued raisesEcto.NoResultsErrorhere, and withmax_attempts: 20plus the 6-hour backoff cap the job fails for days before it is discarded.Repo.get(Contact, contact_id)with anil -> :okbranch (the shapeMechanismClassifyWorkeruses) keeps the delete case quiet, and drops the unused:user/:flagged_by_userpreload this worker never reads.@ -24,0 +32,4 @@# terrain_status is :complete. Edits to grids, band, timestamp or# antenna heights reset the status to :pending, so a pending row# here means "recompute and replace", not "already done".if contact.terrain_status == :complete and Terrain.has_terrain_profile?(contact_id) do🟠 High — Skip gate still trusts terrain_status, which other code sets from row existence
terrain_statusis not a reliable "profile is current" flag, so this still silently skips the recompute the PR is trying to get. Two writers set:completefrom the mere existence of aterrain_profilesrow:ContactLive.Show.maybe_enqueue_terrain/2(lib/microwaveprop_web/live/contact_live/show.ex:830-836) andBackfillEnqueueWorker.reconcile_stale_queued/1(lib/microwaveprop/workers/backfill_enqueue_worker.ex:201-204). Failure path: approve a grid edit (resets to:pendingatimport.ex:372, recompute deferred to the backfill cron), then open/contacts/:idbefore that sweep. The stale row flips:pending->:complete,type_filterno longer selects the contact, and no recompute ever runs. Same trap if the job is already queued and retrying (elevation fetch error keeps the status at:queued): the next 30-min reconcile flips it to:completeand this clause returns:ok. Either compare the row against the contact (profile.updated_at >= contact.updated_at) or stop setting:completewhere the only evidence is that a row exists.dfdddc6160cc76c83a4d🤖 Skippy PR review
2 findings — none blocking.
lib/microwaveprop/workers/terrain_profile_worker.ex:77lib/microwaveprop/radio/contacts.ex:425Force-push:
dfdddc6is no longer in the branch, so this is a full-diff pass atcc76c83. 3 earlier findings are still open (skip gate vs terrain_status, get_contact!/1 on a deleted contact, unhandled :constraint_error in the API + LiveView callers); 0 resolved.Reviewed
cc76c83a4dc0. Commentskippy reviewto re-run.@ -421,0 +422,4 @@# `mix import_contest_logs` (CsvImport.commit/2 with no user_id).# Those rows are bulk contest-log imports, not individual email# submissions, so `source/1` reports them as :archive_import.@contest_import_email "contest-import@ntms.org"🔵 Suggestion — Contest-import sentinel is a second copy of the mix task's literal
@contest_import_emailhas to stay byte-identical to the address passed toCsvImport.preview/2atlib/mix/tasks/import_contest_logs.ex:49, and the new test asserts the same literal, so nothing catches drift: change the address in the task and every contest import silently falls through to:email_submission("the operator behind the submission is unverified") instead of:archive_import. Define it once (aCsvImportfunction or a shared module attribute) and reference it from bothsource/1and the task. Worth noting too thatsubmitter_emailis free text on the anonymous submit form, so classification rests on a user-supplied string; a dedicated column or checking the insert path would be sturdier.@ -62,0 +74,4 @@analysis =TerrainAnalysis.analyse(profile, dist_km, freq_ghz,ant_ht_a: feet_to_m(contact.height1_ft),🔵 Suggestion — Antenna heights only reach profiles computed after deploy (no backfill)
Every stored profile written before this change was analysed with
TerrainAnalysis.analyse/4's default antenna height of 0.0 m - this worker never passed heights - soverdictanddiffraction_dbon those rows are height-blind. Nothing re-queues them: the backfill cron only selects:pending/:queued/:failedplus:unavailableolder than 24 h,mix reset_enrichmentdeliberately skips terrain, and the new skip gate (terrain_status == :completeand a row exists) means a:completerow is never recomputed again unless the contact is edited. Net effect: the Terrain card on/contacts/:idkeeps showing the old height-blind verdict/diffraction for every contact nobody edits, while an identical contact edited once shows the new numbers - and the CHANGELOG tells users the analysis now uses the heights they entered. Either ship a one-off reset with the deploy (terrain_status -> :pendingwhere aterrain_profilesrow exists, then let the queue drain) or scope that changelog line to edited/new contacts.cc76c83a4d9915337811All three findings addressed in
99153378:maybe_enqueue_terrainno longer stamps:completeover a stale row (non-:completefalls through to the enqueue path; an Oban conflict marks:queued, not:unavailable).reconcile_stale_queuednow joinsterrain_profilesand only flips:queued→:completewhentp.updated_at >= c.updated_at, so a profile that predates the edit stays:queuedfor the recompute.:constraint_errorunhandled — added clauses in both callers: API returns 409 without anexistingrow; SubmitLive flashes instead of crashing onto_form(:constraint_error).get_contact!poison job — worker now usesRepo.getand returns:okfor a deleted contact.🤖 Skippy PR review
1 finding — 1 blocking before merge.
lib/microwaveprop/workers/terrain_profile_worker.ex:109Resolved 3 earlier findings fixed in 9915337 (the two row-existence writers of
terrain_status: :complete, the deleted-contact poison job, the unhandled:constraint_errorat the API and LiveView callers); 2 suggestions still open. Noteprev_shacc76c83is no longer in the branch (rebased onto the new main), so this pass re-read the full PR plus the address-review commit.Reviewed
9915337811d5. Commentskippy reviewto re-run.@ -80,0 +106,4 @@verdict: analysis.verdict}) do{:ok, _profile} ->_ = Radio.set_enrichment_status!([contact_id], :terrain_status, :complete)🟡 Warning — A job that started before an edit still stamps :complete over the pre-edit analysis
process/1reads the contact once and then spends the whole elevation fetch (download: true, seconds) on that snapshot, and nothing re-checks it before this line claims:complete. Edit a grid or an antenna height while a terrain job for that contact is in flight: the edit rewritespos1/pos2and resetsterrain_statusto:pending, then the in-flight job finishes, stores a profile built from the pre-edit struct, and stamps:complete. Every reader now trusts that flag: the skip gate here returns:ok,ContactLive.Show.maybe_enqueue_terrain/2returns the contact without enqueueing,BackfillEnqueueWorkerdrops:terrainbecausealready_complete?/2is true, andreconcile_stale_queued/1only inspects:queuedrows. The card then shows the old geometry until the contact is edited again. Capturecontact.updated_atat read time and, before stamping:complete, re-read the row and compare: when it moved, leave the status alone and return:okso the queue recomputes, or re-run the analysis on the fresh struct.reconcile_stale_queued/1'stp.updated_at >= c.updated_attest has the same blind spot, because a profile written by a stale fetch is newer than the edit that invalidated it.