Files
meshcore-analyzer/cmd/server/direct_heard.go
T
Alex BandClaude Opus 5.5 4e91c5cf0b perf(analytics): opt-in pauseWhenIdle skips recomputes nobody reads (#2164)
## Summary

The analytics recomputers (#1240) recompute eleven snapshots (topology,
RF, distance, channels, hash collisions, hash sizes, roles, two
clock-skew views, retransmissions, direct-heard) on a fixed interval,
300 s by default, whether or not anyone reads them. That is the right
trade for a busy instance: reads always hit a warm cache. On a quiet one
it means most of the server's allocation goes on results nobody looks
at.

Measured on one production instance (2 vCPU / 2 GB):

- topology, RF and hash-sizes were each **read 23 times in 45 hours**
while being recomputed every 5 minutes;
- `analyticsRecomputer.runOnce` accounted for **30% of all bytes
allocated** (121 GB of 391 GB in 45 h);
- garbage collection was 87% of the process's CPU (30 s CPU profile:
`gcDrain` 39.9 s of 45.9 s sampled), with an 805 MB live heap against a
1 GiB `GOMEMLIMIT`, so allocation rate translates directly into
collector time.

This PR adds an **opt-in** `analytics.pauseWhenIdle`:

- a recomputer's tick is skipped when nothing has read its snapshot
since the previous compute, and the recomputer is marked paused;
- the next read still returns immediately with the snapshot it holds
(`Load` stays non-blocking, as #1240 requires), and makes a non-blocking
send that wakes the loop to recompute; reads after that get the fresh
snapshot and the ticker resumes from there.

**Default is off**, so behaviour is unchanged unless an operator turns
it on. The trade-off for operators who do: the first read after a quiet
spell can show a snapshot older than the interval (refreshed seconds
later). It is documented in `config.example.json`.

No existing issue covers this.

## Config example

Off by default. To turn it on, add one key to the existing `analytics`
block in `config.json`:

```json
"analytics": {
  "defaultIntervalSeconds": 300,
  "pauseWhenIdle": true
}
```

Nothing else changes: per-endpoint `recomputeIntervalSeconds` still
apply while an endpoint is being read. There is no UI change, so no
screenshot.

## Performance

Staging A/B: the same 30 minutes of real production API traffic (5,640
requests from a day's nginx log, replayed at 2x against a copy of the
production database with the live MQTT feed), on `master` and on this
branch with `pauseWhenIdle: true`, each after a restart and warm-up.
Allocation is the delta of `/debug/pprof/allocs` across the replay:

| during the replay | master | this PR |
|---|---|---|
| allocated by `analyticsRecomputer.runOnce` | 459.7 MB | **53.4 MB
(-88%)** |
| total allocated by the process | 3,704 MB | 3,372 MB (-9%) |
| GC cycles | 52 | 41 |
| requests: p50 / p95 / errors | 9.9 ms / 57.0 ms / 0 | 10.4 ms / 60.1
ms / 0 |

The replayed traffic included real analytics reads, so the paused
recomputers were woken by them; the remaining 53 MB is that. Over 15
minutes each recomputer only ticks about three times, so on a longer
quiet period the saving grows with the idle time. GC count and CPU on
this shared host vary between runs on their own, so the allocation
figures are the claim, not the GC count.

There is no Go benchmark for this change: the behaviour is "how many
computes happen in a period of no reads", which the tests below assert
directly (16 computes on master vs at most 2 here over the same idle
period).

## Tests

- Red commit `24056c57`: config field, accessor stub and an unused
recomputer flag, plus tests. Three fail on the stub:
- `TestAnalyticsRecomputer_PauseWhenIdleSkipsUnreadTicks`: an unread
recomputer with a 20 ms interval computes at most twice in 300 ms (16
times on the stub);
- `TestConfig_AnalyticsPauseWhenIdle`: `pauseWhenIdle: true` enables it,
absent or nil config leaves it off;
- `TestStartAnalyticsRecomputers_AppliesPauseWhenIdle`: all eleven
recomputers receive the flag.
- Also added, passing on both commits on purpose: a read on a paused
recomputer returns the existing snapshot and triggers a refresh; a
recomputer that is being read keeps ticking; the default (off) keeps
ticking unread.
- Green commit `19de3636`: the behaviour, `config.example.json` entry
with `_comment_pauseWhenIdle`, and the `Load` doc comment.
- `go test -race` on the recomputer, warm-up (#1659) and config tests,
three runs: pass. `make test`: 16 of 16 modules pass. The frontend tests
that read `config.example.json` pass.

## Review notes

- `roles` reads the nodes-clock-skew snapshot inside its own compute,
which counts as a read, so that dependency keeps its source fresh while
roles is in use.
- The #1659 warm-up gate is untouched: a skipped tick never marks a
first pass done.
- Config documented per WORKFLOW.md section 7. Customizer (AGENTS.md
rule 8): it is an operator setting, not a display value; no customizer
entry proposed.

## SonarLint

SonarQube Community rules (the SonarLint engine) were run on the files
this PR touches, and only issues on lines it adds are listed.

- No issues on the added lines.

## Checked against the repo rules

- Rebased onto `master` (`673442cc`). Each red commit was re-run and
still fails on its assertions; each green commit passes (TDD.md).
- `make test`: all 16 modules pass. Frontend suites: all pass except
`test-issue-1956-release-routing.js`, which needs CI's `GITHUB_REF_NAME`
and fails the same way on `master` locally (#2160 addresses it).
- No private data: test fixtures and examples use the codes the existing
tests use (SJC/SFO/LAX, `#be`-style scopes).
- No new `map[string]interface{}`, no hard-coded colours. `git add` was
explicit, and there is one logical change per commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01KaR4xu8gVykQBkH8yG8m6H

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:51:48 +02:00

308 lines
11 KiB
Go

// Package main: direct-RF attribution for the node-health "Heard By" card.
//
// An observer "heard" a node when it received that node's own transmission
// off the air. That is a narrower relation than "saw a packet this node was
// involved in", which is what the node-health card reported before this
// file existed, and it is the only one for which the SNR and RSSI printed
// next to an observer belong to the node the row names.
//
// The rule follows the firmware:
//
// - Flood routes build the path up as they travel: a forwarding repeater
// appends its own hash before retransmitting (Mesh.cpp:349). The last
// hop is therefore the node whose transmission the observer received.
// - Direct routes carry the REMAINING route, not the travelled one. A
// forwarder matches itself against the head of the path and calls
// removeSelfFromPath (Mesh.cpp:89,103) before retransmitting, so the
// node the observer heard is not in the path at all. Direct routes
// therefore never attribute.
// - An empty flood path means the observer received the originator's own
// transmission. Only ADVERTs carry the originator's pubkey in the
// clear, so other payload types with an empty path attribute to nobody.
// - A hop prefix that matches more than one node attributes to nobody.
// Path hop sizes are chosen by the originator (Packet.h:83,
// Mesh.cpp:649) and default to one byte, so a hop often matches many
// candidates. resolveWithContext guesses in that case; this file does
// not. The gate matches resolvePathForObsColdLoad: under-attribute
// rather than credit the wrong node.
package main
import (
"sort"
"strings"
)
// directHeardAgg accumulates one observer's direct receptions of one node.
type directHeardAgg struct {
ObserverName string
Count int
SNRSum float64
SNRCount int
RSSISum float64
RSSICount int
}
// directHeardIndex maps node pubkey (lowercase) to observer id to aggregate.
type directHeardIndex map[string]map[string]*directHeardAgg
// HealthObserverRow is one row of the node-health "Heard By" table.
//
// Field names and null semantics are unchanged from the map-based rows this
// replaced, so the frontend needs no migration: avgSnr and avgRssi are null
// when the observer contributed no sample, and can_relay is tri-state (null
// = no repeat field ever reported, see PR #1624).
type HealthObserverRow struct {
ObserverID string `json:"observer_id"`
ObserverName string `json:"observer_name"`
AvgSNR *float64 `json:"avgSnr"`
AvgRSSI *float64 `json:"avgRssi"`
PacketCount int `json:"packetCount"`
CanRelay *bool `json:"can_relay"`
}
// lastPathHop returns the last quoted token of a path_json array, or "" when
// there is none. It scans backwards rather than unmarshalling: the compute
// pass runs over every observation in the store (2.9M on the reference
// deployment) and only ever needs the final element.
func lastPathHop(pathJSON string) string {
end := -1
for i := len(pathJSON) - 1; i >= 0; i-- {
if pathJSON[i] != '"' {
continue
}
if end < 0 {
end = i
continue
}
return pathJSON[i+1 : end]
}
return ""
}
// advertOriginPubkey returns the pubkey an ADVERT announces, or "" when the
// transmission is not an ADVERT or carries no decodable pubkey. Mirrors the
// field probing in trackAdvertPubkey.
func advertOriginPubkey(tx *StoreTx) string {
if tx.PayloadType == nil || *tx.PayloadType != PayloadADVERT || tx.DecodedJSON == "" {
return ""
}
d := tx.ParsedDecoded()
if d == nil {
return ""
}
if v, ok := d["pubKey"].(string); ok && v != "" {
return strings.ToLower(v)
}
if v, ok := d["public_key"].(string); ok && v != "" {
return strings.ToLower(v)
}
return ""
}
// directHeardNode returns the lowercase pubkey of the node whose
// transmission this observation received off the air, or "" when that cannot
// be established. See the package comment for the rule and its firmware
// grounding.
func directHeardNode(tx *StoreTx, obs *StoreObs, pm *prefixMap) string {
if tx == nil || obs == nil || tx.RouteType == nil {
return ""
}
hop := lastPathHop(obs.PathJSON)
switch *tx.RouteType {
case RouteFlood, RouteTransportFlood:
// Handled below: empty path means the originator, otherwise the last
// hop is whoever was heard.
case RouteDirect, RouteTransportDirect:
// A direct route normally says nothing about the transmitter, because
// the forwarder calls removeSelfFromPath before retransmitting
// (firmware Mesh.cpp) and the path left behind is the REMAINING route.
//
// The exception is a zero hop. Mesh::sendZeroHop sets ROUTE_TYPE_DIRECT
// (its transport overload ROUTE_TYPE_TRANSPORT_DIRECT) and path_len = 0,
// commented there as "path_len of zero means Zero Hop". Repeaters send
// their periodic local advert that way (simple_repeater/MyMesh.cpp, the
// next_local_advert branch and sendSelfAdvertisement with flood=false),
// and so do companions. An ADVERT arriving on a direct route with an
// empty path therefore cannot have been forwarded: the observer heard
// the advertiser's own transmission, and the advert carries its pubkey
// in the clear. That is the strongest direct-RF evidence there is.
//
// Anything else direct still says nothing: a non-empty path is the
// remaining route, and advertOriginPubkey returns "" for every payload
// type other than ADVERT.
if hop == "" {
return advertOriginPubkey(tx)
}
return ""
default:
return ""
}
if hop == "" {
return advertOriginPubkey(tx)
}
if pm == nil {
return ""
}
candidates := pm.relayCandidates(hop)
if len(candidates) != 1 {
return ""
}
return strings.ToLower(candidates[0].PublicKey)
}
// buildDirectHeardIndex folds every observation of every transmission into
// the node-to-observer aggregate. Pure over its arguments so it can be
// tested and benchmarked without a store.
func buildDirectHeardIndex(packets []*StoreTx, pm *prefixMap) directHeardIndex {
idx := make(directHeardIndex, 256)
for _, tx := range packets {
for _, obs := range tx.Observations {
if obs.ObserverID == "" {
continue
}
pk := directHeardNode(tx, obs, pm)
if pk == "" {
continue
}
byObs := idx[pk]
if byObs == nil {
byObs = make(map[string]*directHeardAgg, 4)
idx[pk] = byObs
}
agg := byObs[obs.ObserverID]
if agg == nil {
agg = &directHeardAgg{ObserverName: obs.ObserverName}
byObs[obs.ObserverID] = agg
}
agg.Count++
if obs.SNR != nil {
agg.SNRSum += *obs.SNR
agg.SNRCount++
}
if obs.RSSI != nil {
agg.RSSISum += *obs.RSSI
agg.RSSICount++
}
}
}
return idx
}
// computeDirectHeard rebuilds the whole index from the current store. Run by
// a background recomputer rather than per request: the reference deployment
// holds 232,928 transmissions and 2,887,861 observations, and one node's
// byNode slice alone can hold 1.45M observations.
//
// Rebuilding wholesale also means eviction needs no bookkeeping — a pass
// simply does not see transmissions that are gone.
func (s *PacketStore) computeDirectHeard() directHeardIndex {
s.mu.RLock()
defer s.mu.RUnlock()
_, pm := s.getCachedNodesAndPM()
return buildDirectHeardIndex(s.packets, pm)
}
// publishDirectHeard installs a snapshot for readers. Called by the
// recomputer after each pass; a nil-safe no-op for callers that have nothing
// to publish.
func (s *PacketStore) publishDirectHeard(idx directHeardIndex) {
if idx == nil {
idx = directHeardIndex{}
}
s.directHeardSnap.Store(idx)
}
// loadDirectHeard returns the latest snapshot, or nil before the first
// compute has published one. A nil index simply yields empty direct rows:
// the card degrades to "nobody hears this node" until the first pass lands,
// never to a wrong attribution.
//
// It counts as a read of the direct-heard recomputer, so with pauseWhenIdle
// on the card keeps it refreshing (the snapshot is read here, not through
// the recomputer's Load).
func (s *PacketStore) loadDirectHeard() directHeardIndex {
s.analyticsRecomputerMu.RLock()
rc := s.recompDirectHeard
s.analyticsRecomputerMu.RUnlock()
if rc != nil {
rc.markRead()
}
idx, _ := s.directHeardSnap.Load().(directHeardIndex)
return idx
}
// canRelaySets fetches the two inputs behind the can_relay tri-state badge
// (#1290, PR #1624): the observers that reported repeat:off, and the
// observers we have any repeat field for at all. Both are lowercase to match
// pm.nonRelay and GetNonRelayObserverPubkeys; two case conventions on the
// same upstream string would be a latent regression. A read failure degrades
// to "no badge" rather than a wrong badge.
func (s *PacketStore) canRelaySets() (nonRelay, seen map[string]struct{}) {
nonRelay = map[string]struct{}{}
seen = map[string]struct{}{}
if s.db == nil || s.db.conn == nil {
return nonRelay, seen
}
if pks, err := s.db.GetNonRelayObserverPubkeys(); err == nil {
for _, pk := range pks {
nonRelay[strings.ToLower(pk)] = struct{}{}
}
}
if pks, err := s.db.GetCanRelaySeenObserverPubkeys(); err == nil {
for _, pk := range pks {
seen[strings.ToLower(pk)] = struct{}{}
}
}
return nonRelay, seen
}
// relayOnlyObserverCount counts observers that saw traffic involving the node
// without hearing it on air. seenObservers is the set the health builders
// already collect from each transmission's representative observation.
func relayOnlyObserverCount(seenObservers map[string]struct{}, direct map[string]*directHeardAgg) int {
n := 0
for id := range seenObservers {
if _, isDirect := direct[id]; !isDirect {
n++
}
}
return n
}
// buildDirectObserverRows renders one node's aggregate as sorted API rows.
// nonRelay and seen carry the can_relay tri-state; both may be nil.
func buildDirectObserverRows(byObs map[string]*directHeardAgg, nonRelay, seen map[string]struct{}) []HealthObserverRow {
rows := make([]HealthObserverRow, 0, len(byObs))
for id, agg := range byObs {
row := HealthObserverRow{
ObserverID: id,
ObserverName: agg.ObserverName,
PacketCount: agg.Count,
}
if agg.SNRCount > 0 {
v := agg.SNRSum / float64(agg.SNRCount)
row.AvgSNR = &v
}
if agg.RSSICount > 0 {
v := agg.RSSISum / float64(agg.RSSICount)
row.AvgRSSI = &v
}
idLower := strings.ToLower(id)
if _, ok := seen[idLower]; ok {
_, isListener := nonRelay[idLower]
canRelay := !isListener
row.CanRelay = &canRelay
}
rows = append(rows, row)
}
// Packet count descending, observer id ascending as a deterministic
// tiebreak so repeated requests return a stable order.
sort.Slice(rows, func(i, j int) bool {
if rows[i].PacketCount != rows[j].PacketCount {
return rows[i].PacketCount > rows[j].PacketCount
}
return rows[i].ObserverID < rows[j].ObserverID
})
return rows
}