mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
Red commit:cd546b23(fails on assertions, e.g. `region "SAT": got [pk_aus pk_edge pk_far], want [pk_edge pk_far]`) Red commit (fewest-hops fallback):a66a7f36(fails on assertions, e.g. `region "AUS": got [pk_cap pk_gulf pk_near pk_tie], want [pk_cap]`) Red commits (review round 1):d4154ee1(`region_placement` missing),862a4510(`zero: RegionFallbackMaxHops() = 3, want 0`),16dcd8dd(`pk_mixed:{fewest_hops [AUS] hops=0}`, want `[SAT] hops=2`),95f2aca4(`pk_late not placed after the startup load finished`) Fixes #1879 ## What this does `/api/nodes?region=` put a node in every region where any observer heard any of its adverts. Adverts flood, so a node showed up in every region its adverts got relayed into, not just where it actually is. Now, as agreed in the issue: - **Nodes heard zero-hop are placed by their home regions.** Same rule as #804: a zero-hop DIRECT advert can't have been relayed, so the observer that heard it is in RF range. This is the tally `computeNodeHomeRegions()` already does, pulled out into `nodeZeroHopRegionCounts()` so analytics and the filter share one definition. - **It's a set, not a winner.** A node heard zero-hop in two regions is in both. So per-region counts don't add up to the node total anymore, on purpose. - **Everything else goes by fewest hops.** A node never heard zero-hop is in the region(s) whose observers heard it in the fewest hops, and in no region when even that is more than `regionFallback.maxHops` (config, default 3); it still shows unfiltered. The old fallback (every region that heard it at all) turned out to be most of the leak: see the numbers at the bottom. Hearings with no `path_json` (legacy NULL rows) are unknown, not zero hops, and are skipped. - **Only nodes the last recompute didn't cover** (first advert newer than it, or no hearing with a path) still use the old observer check. - **`region_placement` on every node row** (list and detail) says which rule placed it, so an operator can tell the four outcomes apart: ```json {"basis": "zero_hop", "regions": ["AUS"]} {"basis": "fewest_hops", "regions": ["HOU", "SAT"], "hops": 2} {"basis": "none", "hops": 5} {"basis": "observer_scan"} ``` It's a named struct (`NodeRegionPlacement`), documented as its own OpenAPI component. It replaces the earlier `home_regions` field. ## How - `node_home_regions.go`: a recomputer in the `bridge_recomputer.go` shape (sync prewarm, ticker on `AnalyticsDefaultRecomputeInterval()`, atomic snapshot, panics swallowed), started from `main.go` next to the bridge one. It also recomputes once when `StartupLoadDone()` closes, like `recomputeWhenLoaded` does for the analytics recomputers, because the prewarm only sees the first chunk. - Each recompute builds one snapshot, pubkey → `NodeRegionPlacement`, from `nodeZeroHopRegionCounts` plus `nodeFewestHopsByRegion`. Hop counts come from `path_json` without decoding (`pathHopCount`: hop hashes hold no commas). - `RegionNodePubkeys` loads the snapshot (atomic, no lock), skips placed nodes in the observer scan, then adds the ones whose regions intersect the request. The 30 s region cache is unchanged. - `computeNodeHomeRegions()` keeps its plurality output for analytics; only the counting moved. The #804 / #1025 tests are unchanged and pass. - No new `map[string]interface{}`: the node rows set `region_placement` to the struct, the OpenAPI component uses a small typed `openAPISchema`, and the old `enrichNodeHomeRegions` helper is gone. Touched files are at the upstream count. ## Performance - Request path: one atomic load plus map lookups. `RegionNodePubkeys` adds one pass over the snapshot per cache miss, so O(nodes), well under the existing advert scan it sits next to. `BenchmarkRegionNodePubkeys` stays ~19 ms. - Recompute, off the request path, once per interval under `s.mu.RLock`: `BenchmarkRecomputeNodeHomeRegionSets` times the whole function at 220k adverts × 8 observations over 0-7 hop paths (one in eight zero-hop direct) plus 1M channel messages: **~54 ms/op** on an Apple M5 (Docker, linux/arm64), of which the fewest-hops pass (`BenchmarkNodeFewestHopsByRegion`) is ~40 ms. - That's after two small speedups in3dc3cb7d: `nodeZeroHopRegionCounts` walks `byPayloadType[ADVERT]` instead of every packet (same counts), and the fewest-hops pass looks up a node's map once per advert instead of once per observation. Before them the whole recompute was ~72 ms on the same machine. ## Tests - `TestRegionNodePubkeysUsesZeroHopHomeRegions`: a node whose relayed advert reached SAT stays out of SAT; the boundary node is in both; the never-zero-hop node is placed by the fallback. - `TestBoundaryNodeStaysInEachHomeRegion`: the two-region case on its own, so a later refactor can't quietly collapse it back to one winner. - `TestNodeRegionPlacementsZeroHop`: the snapshot for zero-hop and fallback nodes. - `TestHandleNodesRegionPlacement`: `/api/nodes` returns `zero_hop`, `none` and `observer_scan` placements for seeded nodes. - `TestRegionFallbackUsesFewestHops`: fewest hops wins, ties keep both, exactly the cap is placed, past it is in no region (and the observer scan doesn't re-add it), a closer later advert counts. - `TestRegionFallbackMaxHopsConfig`: the config accessor (unset or negative keeps 3, 0 allowed, parsed from JSON) and the recompute honoring it. - `TestRegionFallbackIgnoresUnknownPaths`: an empty `path_json` doesn't count as zero hops; `[]` still does. - `TestRegionFilterObserverScanForNodesNewerThanRecompute`: the observer-scan path for a node newer than the snapshot. - `TestRegionFilterListingEveryRegionOmitsUnplacedNodes`: naming every region explicitly still leaves out a node in no region. - `TestNodeRegionsRecomputeWhenStartupLoadDone`: placements cover a later chunk once the startup load is done. - `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test ./...` pass (`CGO_ENABLED=1`). `gofmt` and `go vet` are clean. No UI change; the API change is covered by the handler test above. ## Config - `regionFallback.maxHops` (default 3), documented in `config.example.json`. - Per AGENTS.md rule 8, this should be exposed in the customizer in a later milestone. Not done here. ## Not in this PR - **Transport-direct zero-hop adverts aren't counted as zero-hop** (pre-existing, `nodeZeroHopRegionCounts`): for `ROUTE_TYPE_TRANSPORT_DIRECT`, `RawHex[2:4]` is the first transport-code byte, not `path_len`. The fallback still places such nodes (their `path_json` is `[]`), but as `fewest_hops`, not `zero_hop`. I'll send that as its own PR since it changes #804 analytics too. - **Memory window.** The answer only covers adverts still in the packet store, so quiet nodes fall back. That's the long-tail point from the thread. A `node_regions` table kept by the ingestor (the `UpdateNodeDefaultScope` pattern) would remove the window; happy to do that as a follow-up if you want it. - **One-off long-range hearings.** A single zero-hop hearing during a ducting event still adds that region. On our deployment we run the same rule with a 20% share threshold per region (ignore a region holding under a fifth of a node's direct hearings), which drops those. Easy to add here if you'd like it. - **Flood adverts heard with an empty path** are direct evidence too, and much more common than zero-hop DIRECT adverts. Left out of the zero-hop rule to keep one definition with #804; the fallback places them at 0 hops. - **Moving #804 / #1025 analytics to the set version**, as you suggested, after this lands. **Fallback numbers.** On our network only ~27% of nodes are ever heard directly, so the fallback decides most of the filter. With "every region that heard it", our San Antonio filter showed 241 nodes whose GPS puts them outside SAT against 149 inside, and Austin picked up Louisiana and Gulf Coast nodes heard 3-10 hops out. We've been running the fewest-hops/cap-3 rule in production since 2026-10-14; nodes with GPS outside the selected region went SAT 241 → 7, DFW 76 → 7, HOU 47 → 2, AUS 226 → 48. About 240 nodes are in no region (they still show unfiltered). For context, on our network (Texas, ~1,500 nodes) the same idea with the broader "heard directly" rule took San Antonio from 543 nodes to 482. The 61 removed were all Austin-area nodes whose adverts get relayed south, and only 3 nodes statewide are heard directly in two regions.
264 lines
8.9 KiB
Go
264 lines
8.9 KiB
Go
// Package main: zero-hop home regions for the /api/nodes region filter (#1879).
|
|
//
|
|
// The region filter used to put a node in every region where any observer
|
|
// heard any of its adverts. Adverts are flooded, so a node leaked into each
|
|
// region its adverts were relayed into. A zero-hop DIRECT advert is not
|
|
// relayed, so an observer that hears one is within RF range of the node (#804).
|
|
// This recomputer turns those hearings into a set of home regions per node.
|
|
//
|
|
// A node never heard zero-hop goes in the regions that heard it in the fewest
|
|
// hops, and in no region when even that is over regionFallback.maxHops (it still
|
|
// shows unfiltered). Every region that heard it at all, the old fallback, put
|
|
// most nodes in every region on a statewide deployment: adverts flood the mesh,
|
|
// so observers in each metro hear far-off nodes many hops out. The observer-
|
|
// based check now decides only for nodes newer than the last recompute.
|
|
//
|
|
// Each node's outcome is a NodeRegionPlacement, which RegionNodePubkeys reads
|
|
// and /api/nodes returns as region_placement, so an operator can see which of
|
|
// the four rules placed a node.
|
|
//
|
|
// A set rather than #804's plurality winner: a node on a region boundary,
|
|
// heard zero-hop in two regions, belongs to both, so per-region node counts
|
|
// no longer sum to the node total.
|
|
//
|
|
// Same shape as bridge_recomputer.go: background ticker, atomic snapshot,
|
|
// synchronous prewarm at start, plus one recompute when the startup load is
|
|
// done. Each recompute makes two passes over the in-memory adverts (zero-hop
|
|
// counts, then fewest hops; decoded JSON cached on the tx) under s.mu read,
|
|
// off the request path: BenchmarkRecomputeNodeHomeRegionSets. RegionNodePubkeys
|
|
// and handleNodes read the snapshot with a single atomic load.
|
|
package main
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const nodeHomeRegionsRecomputerDefaultInterval = 5 * time.Minute
|
|
|
|
// regionFallbackMaxHopsDefault bounds the fallback for nodes never heard
|
|
// zero-hop: a node whose closest hearing took more hops than this is in no
|
|
// region. Config regionFallback.maxHops overrides it (Config.RegionFallbackMaxHops).
|
|
const regionFallbackMaxHopsDefault = 3
|
|
|
|
var (
|
|
nodeHomeRegionsRecompStartedMu sync.Mutex
|
|
nodeHomeRegionsRecompStarted bool
|
|
)
|
|
|
|
// StartNodeHomeRegionsRecomputer computes the region placements once, again
|
|
// when the startup load is done, then every interval (default 5min if <= 0). Idempotent: later calls are no-ops
|
|
// and return a no-op stop closure.
|
|
func (s *PacketStore) StartNodeHomeRegionsRecomputer(interval time.Duration) func() {
|
|
if interval <= 0 {
|
|
interval = nodeHomeRegionsRecomputerDefaultInterval
|
|
}
|
|
|
|
nodeHomeRegionsRecompStartedMu.Lock()
|
|
if nodeHomeRegionsRecompStarted {
|
|
nodeHomeRegionsRecompStartedMu.Unlock()
|
|
return func() {}
|
|
}
|
|
nodeHomeRegionsRecompStarted = true
|
|
stop := make(chan struct{})
|
|
done := make(chan struct{})
|
|
nodeHomeRegionsRecompStartedMu.Unlock()
|
|
|
|
s.recomputeNodeHomeRegionSets()
|
|
|
|
// main.go starts this at the first load chunk, so the prewarm saw only
|
|
// part of the store. Recompute once the whole startup load (hot window
|
|
// and background fill) is done, as the analytics recomputers do.
|
|
loaded := s.StartupLoadDone()
|
|
|
|
var stopOnce sync.Once
|
|
go func() {
|
|
defer close(done)
|
|
t := time.NewTicker(interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-loaded:
|
|
loaded = nil // once
|
|
s.recomputeNodeHomeRegionSets()
|
|
case <-t.C:
|
|
s.recomputeNodeHomeRegionSets()
|
|
case <-stop:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
return func() {
|
|
stopOnce.Do(func() {
|
|
close(stop)
|
|
})
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
// recomputeNodeHomeRegionSets rebuilds and installs the snapshot. A panic in
|
|
// compute is swallowed so the goroutine never dies; the previous snapshot
|
|
// stays valid.
|
|
func (s *PacketStore) recomputeNodeHomeRegionSets() {
|
|
defer func() { _ = recover() }()
|
|
// The observers table is read before s.mu, as for computeNodeHomeRegions (#2146).
|
|
obsIATA := s.observerIATAs()
|
|
var counts, hops map[string]map[string]int
|
|
func() {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
counts = s.nodeZeroHopRegionCounts(obsIATA)
|
|
hops = s.nodeFewestHopsByRegion(obsIATA)
|
|
}()
|
|
placements := regionPlacements(counts, hops, s.config.RegionFallbackMaxHops())
|
|
s.nodeRegionPlacements.Store(&placements)
|
|
}
|
|
|
|
// nodeFewestHopsByRegion returns, per advert pubkey, the fewest hops any
|
|
// observer in each region heard it over. Caller must hold s.mu (read).
|
|
func (s *PacketStore) nodeFewestHopsByRegion(obsIATA map[string]string) map[string]map[string]int {
|
|
per := make(map[string]map[string]int, 256)
|
|
if len(obsIATA) == 0 {
|
|
return per
|
|
}
|
|
for _, tx := range s.byPayloadType[PayloadADVERT] {
|
|
var m map[string]int
|
|
for _, obs := range tx.Observations {
|
|
iata := obsIATA[obs.ObserverID]
|
|
if iata == "" {
|
|
continue
|
|
}
|
|
h, known := pathHopCount(obs.PathJSON)
|
|
if !known {
|
|
continue
|
|
}
|
|
if m == nil {
|
|
// Parsed only for adverts heard in some region, and cached on the tx.
|
|
pk := advertPubkey(tx)
|
|
if pk == "" {
|
|
break
|
|
}
|
|
if m = per[pk]; m == nil {
|
|
m = map[string]int{}
|
|
per[pk] = m
|
|
}
|
|
}
|
|
if cur, ok := m[iata]; !ok || h < cur {
|
|
m[iata] = h
|
|
}
|
|
}
|
|
}
|
|
return per
|
|
}
|
|
|
|
// pathHopCount counts the hops in an observation's path_json without decoding
|
|
// it: the elements are hop-hash hex strings, so they hold no commas. Run over
|
|
// every advert observation per recompute, under s.mu, where pathLen's
|
|
// json.Unmarshal would allocate per observation. known is false for a missing
|
|
// path (legacy rows with NULL path_json): "[]" is a direct hearing, "" is not.
|
|
func pathHopCount(pathJSON string) (hops int, known bool) {
|
|
p := strings.TrimSpace(pathJSON)
|
|
switch p {
|
|
case "", "null":
|
|
return 0, false
|
|
case "[]":
|
|
return 0, true
|
|
}
|
|
return strings.Count(p, ",") + 1, true
|
|
}
|
|
|
|
// RegionBasis names the rule that decided a node's regions for ?region=.
|
|
type RegionBasis string
|
|
|
|
const (
|
|
// RegionBasisZeroHop: observers heard the node zero-hop; Regions are
|
|
// theirs.
|
|
RegionBasisZeroHop RegionBasis = "zero_hop"
|
|
// RegionBasisFewestHops: never heard zero-hop; Regions heard it in the
|
|
// fewest hops (Hops), at most regionFallback.maxHops.
|
|
RegionBasisFewestHops RegionBasis = "fewest_hops"
|
|
// RegionBasisNone: never heard zero-hop, and its closest hearing (Hops)
|
|
// is over regionFallback.maxHops, so it is in no region.
|
|
RegionBasisNone RegionBasis = "none"
|
|
// RegionBasisObserverScan: not in the last recompute (its first advert
|
|
// is newer, the store holds none, or every hearing lacks a path); the
|
|
// filter puts it in each region whose observers heard any of its adverts.
|
|
RegionBasisObserverScan RegionBasis = "observer_scan"
|
|
)
|
|
|
|
// NodeRegionPlacement is which rule placed a node for the /api/nodes region
|
|
// filter, and where (#1879). Returned on each node as region_placement.
|
|
type NodeRegionPlacement struct {
|
|
Basis RegionBasis `json:"basis"`
|
|
// Regions is sorted; absent for none and observer_scan.
|
|
Regions []string `json:"regions,omitempty"`
|
|
// Hops is the fewest hops any region heard the node over; set for
|
|
// fewest_hops and none.
|
|
Hops *int `json:"hops,omitempty"`
|
|
}
|
|
|
|
// regionPlacements places every node in zeroHop (per-region zero-hop counts)
|
|
// by those regions, and every other node in fewest (per-region fewest hops)
|
|
// by its fewest-hop regions, or in none past maxHops.
|
|
func regionPlacements(zeroHop, fewest map[string]map[string]int, maxHops int) map[string]NodeRegionPlacement {
|
|
out := make(map[string]NodeRegionPlacement, len(fewest))
|
|
for pk, perIATA := range zeroHop {
|
|
regions := make([]string, 0, len(perIATA))
|
|
for iata := range perIATA {
|
|
regions = append(regions, iata)
|
|
}
|
|
sort.Strings(regions)
|
|
out[pk] = NodeRegionPlacement{Basis: RegionBasisZeroHop, Regions: regions}
|
|
}
|
|
for pk, perIATA := range fewest {
|
|
if _, placed := out[pk]; placed || len(perIATA) == 0 {
|
|
continue
|
|
}
|
|
hops := -1
|
|
for _, h := range perIATA {
|
|
if hops < 0 || h < hops {
|
|
hops = h
|
|
}
|
|
}
|
|
if hops > maxHops {
|
|
out[pk] = NodeRegionPlacement{Basis: RegionBasisNone, Hops: &hops}
|
|
continue
|
|
}
|
|
regions := []string{}
|
|
for iata, h := range perIATA {
|
|
if h == hops {
|
|
regions = append(regions, iata)
|
|
}
|
|
}
|
|
sort.Strings(regions)
|
|
out[pk] = NodeRegionPlacement{Basis: RegionBasisFewestHops, Regions: regions, Hops: &hops}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetNodeRegionPlacements returns the current snapshot (pubkey → placement),
|
|
// or an empty map before the first compute. A node absent from it is placed by
|
|
// the observer scan. Read-only by convention: the map is shared by all readers.
|
|
func (s *PacketStore) GetNodeRegionPlacements() map[string]NodeRegionPlacement {
|
|
snap := s.nodeRegionPlacements.Load()
|
|
if snap == nil {
|
|
return map[string]NodeRegionPlacement{}
|
|
}
|
|
return *snap
|
|
}
|
|
|
|
// RegionPlacementFor returns a node's placement from a snapshot, observer_scan
|
|
// when the snapshot does not cover it.
|
|
func RegionPlacementFor(placements map[string]NodeRegionPlacement, pubkey string) NodeRegionPlacement {
|
|
if p, ok := placements[pubkey]; ok {
|
|
return p
|
|
}
|
|
return NodeRegionPlacement{Basis: RegionBasisObserverScan}
|
|
}
|