fix(mqtt): stop lastHeard-refresh upserts from clobbering NodeInfo#3456
Conversation
The MQTT POSITION/TEXT/TELEMETRY/PAXCOUNTER/S&F-heartbeat handlers each upsert the sender node to bump lastHeard, and previously hardcoded longName: '', shortName: '', hwModel: 0. The upsert merge treats an empty string / 0 as a provided value (setIfProvided on SQLite, ?? on PG/MySQL), so the next position/telemetry packet after a NODEINFO_APP packet wiped the saved name. Since nodes broadcast NodeInfo only every few hours but position/telemetry constantly, MQTT nodes appeared nameless almost always. Omit longName/shortName/hwModel from these refresh upserts so the merge preserves existing NodeInfo. The NODEINFO_APP handler (the one upsert that legitimately carries real values) is untouched. Node dedup on (nodeNum, sourceId) was already correct — this was a clobber, not a dup. Adds a regression suite asserting refresh upserts no longer carry name fields while NODEINFO_APP still saves them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @Yeraze's task —— View job Code Review Complete ✅Reviewing PR #3456: "fix(mqtt): stop lastHeard-refresh upserts from clobbering NodeInfo" Review Checklist:
SummaryThis is an excellent fix for a subtle but significant bug in MQTT ingestion. The problem was well-diagnosed and the solution is both minimal and correct. Code Quality & Best Practices ✅Strengths:
Potential Issues ✅No significant issues found:
Performance Considerations ✅Positive impact:
Security Concerns ✅No security issues:
Test Coverage ✅Comprehensive test suite:
Test quality highlights:
Architecture Alignment ✅Follows repository guidelines:
Minor Suggestions
Verdict: APPROVED ✅This is a high-quality fix that:
The problem analysis was thorough, the solution is elegant, and the test coverage ensures this regression won't reoccur. Excellent work! |
Bump version to 4.10.3 across package.json, package-lock.json, helm/meshmonitor/Chart.yaml, desktop/package.json, and desktop/src-tauri/tauri.conf.json. Finalize the CHANGELOG: promote [Unreleased] to [4.10.3] and add the entries merged after the section was last written — MQTT NodeInfo clobber fix (#3456), Traffic Management/StatusMessage firmware-version gate (#3457), local-node module config storage (#3460), phantom channel swap fix (#3453), and the TX power help-text clarification (#3459). Add a release blog post highlighting the Traffic Management detection fix and the bug-fix round. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…3512) The INSERT … ON CONFLICT DO UPDATE race-fallback (upsertSet) still used the bare ?? null pattern, letting ''/0 persist and reintroducing the #3456/#3505 clobber under a concurrent-insert race. Normalize blank strings / hwModel 0 to NULL there (no existing row is fetched on the insert branch, so the nameOrExisting helper can't apply — blank→null is the consistent fix). Also add async-path (PG/MySQL-compatible) guard tests to the shared nodes.test.ts suite, per review feedback, mirroring the SQLite guard tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…3505) (#3512) * fix(nodes): don't clobber learned name/macaddr/hwModel with blanks (#3505) The node upsert merge guard treated '' and 0 as "provided", so a future caller passing a blank name or hwModel 0 would overwrite a stored value — the latent hole behind #3456 (which was fixed only at the mqttIngestion call site, not the guard). This is the durable form of that fix. Per-column policy (not a blanket skip-empty, since 0 is legitimate for most numerics): - SQLite upsertNodeSqlite: longName/shortName/macaddr now use a setIfNonBlank guard ('' preserves existing); hwModel skips 0 (HardwareModel.UNSET). - Async PG/MySQL update: same via a nameOrExisting() helper + hwModel-0 guard. - snr/rssi/battery/voltage/lat-lon/role etc. still write 0 (genuine zeros). Adds nodes.upsertGuard.test.ts: a blank re-upsert preserves name/macaddr/hwModel, while snr:0/batteryLevel:0 still write. (New file rather than editing nodes.test.ts to avoid colliding with the in-flight #3501 test-DB migration.) Closes #3505. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(nodes): guard upsertSet race-fallback + cover async path (review #3512) The INSERT … ON CONFLICT DO UPDATE race-fallback (upsertSet) still used the bare ?? null pattern, letting ''/0 persist and reintroducing the #3456/#3505 clobber under a concurrent-insert race. Normalize blank strings / hwModel 0 to NULL there (no existing row is fetched on the insert branch, so the nameOrExisting helper can't apply — blank→null is the consistent fix). Also add async-path (PG/MySQL-compatible) guard tests to the shared nodes.test.ts suite, per review feedback, mirroring the SQLite guard tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(nodes): use blankToNull for macaddr in upsertSet too (review #3512) Consistency: macaddr in the ON CONFLICT DO UPDATE block still used ?? null while longName/shortName already use blankToNull. Align it so a blank '' macaddr normalizes to null on the conflict-update path like the other identity fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
Many nodes seen on MQTT had no name (longName/shortName) and
hwModel: 0, despite their NodeInfo being received. Investigation showed this was not a decoding failure and not a de-dup failure — node de-dup on(nodeNum, sourceId)is correct and produces no duplicate rows. NodeInfo was being decoded and saved fine, then wiped microseconds later.Root cause
The MQTT POSITION / TEXT / TELEMETRY / PAXCOUNTER / Store-&-Forward-heartbeat handlers each upsert the sender node only to refresh
lastHeard, but hardcoded:The upsert merge treats an empty string /
0as a provided value, not "absent":upsertNodeSqlite):setIfProvidedguards only on!== null && !== undefined, so''/0get written.nodeData.longName ?? existing—''is not nullish, so it passes through.A node broadcasts NodeInfo only every few hours but sends position/telemetry constantly, so the name was overwritten back to empty almost immediately after every NodeInfo packet. Net effect: MQTT nodes appear nameless nearly all the time.
This is unique to the MQTT ingest path — the TCP path omits the name fields on non-NodeInfo updates, so its
??/setIfProvidedmerges already preserve them.Fix
Omit
longName/shortName/hwModelfrom the fivelastHeard-refresh upserts so the merge preserves whatever NodeInfo already exists. TheNODEINFO_APPhandler — the one upsert that legitimately carries real values — is untouched.Tests
Adds a regression suite (
lastHeard refresh must not clobber NodeInfo):POSITION / TEXT / TELEMETRY / PAXCOUNTER / S&F-heartbeat refresh upserts now have
longName/shortName/hwModelundefined (not''/0) while still settinglastHeard.Positive control:
NODEINFO_APPstill saves the real name/shortName/hwModel.mqttIngestion.test.ts: 40/40 passFull suite: 6403 pass, 0 fail, 0 suite failures
tsc --noEmit: clean🤖 Generated with Claude Code