Repository navigation
port(upstream#1885): retained MQTT status is not observer liveness - #26
Conversation
…a-clawbot#1885) ## Problem The broker replays every retained `status` message on subscribe, so each ingestor restart pushes all of them through the status path in `handleMessage`. That path stamps `last_seen` with `time.Now()` (deliberately, per Kpa-clawbot#1465). Observed on a live deployment on 2026-08-11: **23 observers all carried `last_seen = 2026-08-06T08:20:09Z`** — 15 seconds after container start — and 18 of them had sent no actual packet in over a month. Their retained publish dates lined up almost 1:1 with `last_packet_at`, i.e. the replay was their only sign of "life": | observer | last real packet | retained status published | |---|---|---| | ON8AR - Observer | never | 2026-03-20 | | BE-BGS-RRY120-RES | never | 2026-04-02 | | A3BEF374 | 2026-04-30 | 2026-04-30 | | BE-BGS-RRY120-RUDY | 2026-05-18 | 2026-05-18 | | BE-JBE-ETG-O1 | 2026-06-10 | 2026-06-10 | That makes dead observers immortal, three ways per restart: 1. `last_seen` jumps forward, so `RemoveStaleObservers` can never age them out as long as a restart happens inside `observerDays`. 2. The unconditional `inactive = 0` reactivation at the end of `UpsertObserverAt` undoes any soft-delete that did land. 3. A metrics sample is filed at ingest time, dating a months-old reading as a present-tense measurement. `UpsertObserverAt`'s docstring already claimed retained replays were a no-op for `last_seen` thanks to the `MAX` guard. That held only while the caller passed the envelope timestamp; Kpa-clawbot#1465 switched it to ingest time, which defeats the guard. ## Fix The retained path now updates metadata only, via a new `UpsertObserverRetained`: - no `last_seen` advance - no `inactive = 0` reactivation - no `packet_count` bump - no metrics sample - **no INSERT** — a retained-only observer the analyzer has never heard from live describes a past that may be months old and does not belong in the list. A live message from the same observer creates the row through the normal path moments later. Live status handling is unchanged. ## Tests Seven tests in `cmd/ingestor/retained_status_test.go`, written before the fix: - 4 that failed on the bug: `last_seen` advance, reactivation of a soft-deleted row, creation of a never-seen observer, metrics-sample insert - 2 regression guards pinning live (non-retained) behaviour: `last_seen` still advances, unknown observer still created - 1 asserting retained metadata is still applied — the snapshot is the observer's last known state, only the liveness signal is suppressed `mockMessage` gained a `retained` field so `Retained()` is controllable. Meta flattening is extracted to `observerMetaColumns` so both write paths bind identical args. Full `cmd/ingestor` suite passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 9bd5f5a) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An independent mutation run against the port's original seven tests left nine mutants alive. Six of them sit on behaviour the change is actually responsible for; these tests kill all six, each verified by re-applying the mutation and watching the named test fail on a real assertion (not a compile error). packet_count bump re-added to UpsertObserverRetained -> killed can_relay_seen regressed 1 -> 0 on the retained path -> killed strings.EqualFold replaced by a case-sensitive == -> killed the `s != ""` guard dropped -> killed !t.Before(cutoff) flipped to t.After(cutoff) -> killed IATA TrimSpace/ToUpper dropped on the retained path -> killed Two of these matter more than their size suggests. Losing the case-insensitive comparison would silently suppress liveness for any firmware publishing "ONLINE" — the observer would age out while alive, which is worse than the bug this port fixes. And can_relay_seen is the tristate's "we have actually observed this" bit (Kpa-clawbot#1290); a replay carrying no `repeat` field must not erase an answer the live path recorded. The three remaining survivors are left deliberately and are noted in the file: two concern statusLivenessMaxAge's exact value, which is documented as a generous margin rather than a threshold, and one concerns Stats.ObserverUpserts, a counter with no behavioural consequence. No production code and no existing assertion was changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uafhængig review + målt verifikation — ingen blockersGennemgået af en reviewer, der ikke skrev ændringen, med krav om at eksekvere frem for at ræsonnere. Branchen er synkroniseret med master Kerneregressionen er rettet — begge halvdele verificeretSamme payload (
Ukendt observer: retained indsætter intet ( Begge halvdele var påkrævet. En rettelse, der også knækkede levende observers, ville være værre end fejlen. Grænseadfærd (46 tilfælde, alle som dokumenteret)
Dokumenteret begrænsning: fail-open12 af 14 payload-former genopliver stadig en soft-deleted observer fuldstændigt, når RETAIN er ryddet — herunder alle de former, repoets egne eksisterende tests bruger ( Ærligt forbehold fra revieweren: de fixtures er syntetiske minimalpayloads, ikke opsamlet produktionstrafik, og det kunne ikke afgøres fra repoet, hvad rigtige payloads indeholder. Nettopositionen: rettelsen er strengt bedre end master overalt og aldrig værre, retain-flag-halvdelen fanger 100 % af replays på egen subscribe, og den konkrete hændelse PR'en citerer (payload der siger Testhuller lukket (
|
| mutation | resultat |
|---|---|
packet_count + 1 genindsat i UpsertObserverRetained |
dræbt |
can_relay_seen regresserer 1 → 0 |
dræbt |
strings.EqualFold → case-sensitiv == |
dræbt |
s != ""-garden fjernet |
dræbt |
!t.Before(cutoff) → t.After(cutoff) |
dræbt |
| IATA-normalisering fjernet på retained-stien | dræbt |
Den tredje fortjener en note: mistes case-ufølsomheden, ville enhver firmware, der publicerer "ONLINE", få sin liveness stille undertrykt — observeren ville ælde ud, mens den er i live. Det er værre end den fejl, porten retter, og det var helt utestet.
De tre resterende overlevende er bevidst efterladt og noteret i filen: to angår statusLivenessMaxAge's eksakte værdi, som er dokumenteret som en generøs margin og ikke en tærskel, og én angår Stats.ObserverUpserts, en tæller uden adfærdsmæssig konsekvens.
Revieweren rapporterede desuden en fejl i sit eget harness: første mutationsdriver pipede go test | tail og returnerede dermed tails exitkode, så alt så ud til at overleve. Den blev fanget, da en umulig mutant "overlevede", rettet med pipefail og alt blev kørt om. De tal, der står her, er fra den rettede driver.
Kontrolleret og fundet i orden
- COALESCE med tom streng er pre-eksisterende, ikke ny. A/B på store-niveau med identiske argumenter:
UpsertObserverRetained("ab1","","",nil)ogUpsertObserverAt("ab2","","",nil,"")efterlader beggename="",iata=""— identisk. Verificeret igen mod master-baseline16ffe105. Den udtrukneobserverMetaColumnser tekstuelt adfærdsbevarende mod masters inline-blok. - Ingen ny låserisiko.
SetMaxOpenConns(1)+busy_timeout=5000serialiserer i puljen, før SQLite ser kontention. 1200 samtidige operationer over fire goroutiner (retained upserts, liveUpsertObserverAt,InsertTransmissionmedwriterMu, og enWriterExec-kalder): 0 fejl, 0WriteErrors, ingen race. Den live-sti, den erstatter, omgår ogsåwriterMu, så lådisciplinen er uændret. Bonus: retained-stien er 5× billigere (16,4 µs/op mod 80,5 µs/op). can_relay_seenhar eksakt paritet med live-stien: 18 kombinationer (3 førtilstande × 6 payloads), identiske i hver celle, og flaget regresserer aldrig 1 → 0.go test -race ./...på den synkroniserede branch: grøn, 315 s, 0 races, 0 fejl. Revieweren så her en race, men den var pre-eksisterende — den lækkedeStartStatsFileWriter-goroutine — og den blev rettet på master af test(ingestor): stop background work from outliving the test that started it #71 (96319acc), som nu er med i denne branchs merge-base. Det er en direkte bekræftelse af, at test(ingestor): stop background work from outliving the test that started it #71 var værd at lave.
SHOULD-FIX, som jeg bevidst ikke retter her
De ville ændre porten væk fra upstream #1885 og koste paritet for fremtidige ports. Dokumenteret i stedet:
statuser en whitelist med præcis én værdi. Målt:"connected","up","ok"giver allefalse. Firmware med andet ordforråd ville få liveness permanent slået fra.- Ingen
TrimSpacepåstatus— målt:" online"og"online "giver begge ikke-live, mensTrimSpaceanvendes pårepeat(main.go:1232) ogiata(db.go:1448). Inkonsistent. - Fremtidige tidsstempler er ubegrænsede — målt:
now+10 år→ live.resolveRxTime(main.go:1307) afviser hårdt >14t fremtid af netop denne grund. Asymmetrien ser utilsigtet ud. - Scope-hul: garden sidder kun i
/status-grenen. Målt: en retained pakke genopliver stadig (last_seenrykkede,inactive1→0,packet_count7→8) via den ugarderedemain.go:887. En retained/neighbors-rapport gør korrekt ikke (målt). - Identisk loglinje for undertrykt og live status (
main.go:648og:674). En operatør kan ikke se på loggen, at liveness blev undertrykt — uheldigt for en rettelse, hvis pointe er at diagnosticere en replay-storm.
Hvorfor denne PR er PARKERET, ikke merget
Opgavens regler kræver staging før merge for MQTT-/liveness-ændringer, og denne har desuden synlig dataeffekt: observers kan forsvinde fra listen. Den handler om broker-adfærd (EMQX → mosquitto-bridge → ingestor), som CI ikke kan reproducere.
Staging kan ikke nås fra den maskine, arbejdet kører på: docker-dæmonen kører ikke, ~/meshcore-staging-data findes ikke, der er ingen ~/.ssh/config og ingen remote docker-context. Deploy-jobbet kører på den self-hostede runner [self-hosted, meshcore-runner-2], som er fork-guarded fra her. Docker blev bevidst ikke startet: containerne har restart: unless-stopped, så en dæmonstart kunne rejse en ingestor, der forbinder til en live broker.
Konkret testplan ligger i STAGING-TESTPLAN.md under "#26". Det vigtigste punkt der: verificér at en LEVENDE observer stadig får last_seen opdateret — uden det bevis er staging-kørslen ikke gyldig.
🤖 Generated with Claude Code
Split out of #25 (commit
c28a20b9there). This branch holds exactly one upstream change so it can be reviewed, tested and reverted on its own.Upstream
9bd5f5a3a2647f42091d8cb7a04ac98b7f018d17git cherry-pick -xonto masterfda24ca5; upstream authorship kept, and the commit message carries the(cherry picked from commit …)line.cmd/ingestoronly.Problem
The MQTT broker replays every retained
statusmessage on (re)subscribe, and the status path stampsobservers.last_seenwith ingest time and clearsinactive. Every ingestor restart therefore marks long-dead observers as alive, soRemoveStaleObserverscan never age them out. A replay that arrives through a bridging broker loses the RETAIN flag, so the flag alone cannot catch it.Change
handleMessagetreats a status message as a replay whenm.Retained()is set or the newstatusIsLivenessrejects the payload: a status other thanonline, or a payload timestamp older than 24 h (payloads with no or unparseable timestamp keep the old behaviour). Replays go through the newStore.UpsertObserverRetained, which only refreshes metadata columns on an existing row. It does not advancelast_seen, clearinactive, bumppacket_countor insert new rows. The column flattening shared by both upserts moved intoobserverMetaColumns.Adaptation to this fork
None. The cherry-pick applied without conflicts and the changed lines are identical to upstream.
Notes for review
Behaviour change operators may notice: observers whose only traffic is a replayed status snapshot no longer reappear as active after a restart.
Dependencies and merge order
fda24ca5and needs no other PR from this split.TestPruneOldNeighborMetricsdeterministic). If test(ingestor): make neighbor metrics pruning deterministic #33 lands first, the expected CI failure named below disappears; nothing in this PR depends on it.Verification
Local run of the same commands as CI's “Go Build & Test” job (server tests with
-race), on this branch and on masterfda24ca5under the same conditions (same machine, run one after another):fda24ca5go-ingestor-build-vetgo-ingestor-testTestBackfillTxLastSeen_ResolvesFromMaxObservationTimestamp,TestPruneOldNeighborMetricschannel-lib-testdecrypt-cli-build-testdockerfile-copy-invariantsdeclare -A), macOS has 3.2; identical on masterstaging-disk-monitorcss-vars-lintBaseline failures (fail identically on master; not introduced or changed here): see rows marked baseline failure, unchanged.
Browser validation (local, fixture DB, no staging/production): Not applicable (no frontend change).
Not run:
eslint(not installed locally; CI installs it on the fly).Expected GitHub CI: “Go Build & Test” is expected to fail on
TestPruneOldNeighborMetrics, which already fails on master (see #25's run). Downstream jobs (Playwright, image build) are therefore skipped. “Deploy Staging” and all GHCR publish steps only run onpushtomasterand cannot run for this PR.Two further ingestor tests have failed intermittently in this split's CI on branches whose
cmd/ingestortree is byte-identical to master (#27, #28), so they can also appear here without being caused by this change:TestBackfillTxLastSeen_ResolvesFromMaxObservationTimestamp: also reproduced locally on unmodified master.TestMQTTStallWatchdog_DisconnectedEscalationThrottled_1749: the suite flake that upstream test(ingestor): join the watchdog loop goroutine instead of only asking it to stop Kpa-clawbot/CoreScope#2003 (also split out of port(upstream): 26 clean upstream fixes — prune batching, /ws limits, observer liveness, watchdog race #25) addresses.GitHub CI result: run 34749188316 on
40f804d8. Go Build & Test: failure; all downstream jobs incl. Deploy Staging skipped. Failed tests:TestPruneOldNeighborMetrics: fails on master, documented baseline🤖 Generated with Claude Code