mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 02:57:22 +00:00
## 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>
308 lines
11 KiB
Go
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
|
|
}
|