fix(nodes): place never-zero-hop nodes by their fewest hops, capped at 3 (#1879)

The fallback for nodes never heard zero-hop put them in every region
that heard any of their adverts. Adverts flood the whole mesh, so on a
statewide deployment most nodes landed in every region. Such nodes now
go in the regions that heard them in the fewest hops, and in none when
even that is over 3 hops. The recomputer computes this alongside the
home regions; the observer-based scan decides only for nodes newer than
the last recompute.

Perf: one extra pass over in-memory advert observations per recompute
(5 min), hop counts from path_json without decoding. 220k adverts x 8
observers: ~50ms under the read lock. RegionNodePubkeys adds one map
lookup per heard advert (BenchmarkRegionNodePubkeys ~20ms, unchanged).
This commit is contained in:
Adam Nieto (MeshTexas) authored and Adam Nieto committed 2026-10-15 08:14:57 -05:00
1 parent a66a7f36ee
commit 4a67e5047f
4 files changed
+152 -17

No files matched your search

+95 -5
View File
@@ -5,8 +5,14 @@
// 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;
// RegionNodePubkeys uses the set for nodes that have one, and keeps the
// observer-based check only for nodes never heard zero-hop.
// RegionNodePubkeys uses the set for nodes that have one.
//
// 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 regionFallbackMaxHops (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.
//
// 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
@@ -21,6 +27,7 @@ package main
import (
"sort"
"strings"
"sync"
"time"
)
@@ -89,13 +96,92 @@ func (s *PacketStore) recomputeNodeHomeRegionSets() {
defer func() { _ = recover() }()
// The observers table is read before s.mu, as for computeNodeHomeRegions (#2146).
obsIATA := s.observerIATAs()
counts := func() map[string]map[string]int {
var counts, hops map[string]map[string]int
func() {
s.mu.RLock()
defer s.mu.RUnlock()
return s.nodeZeroHopRegionCounts(obsIATA)
counts = s.nodeZeroHopRegionCounts(obsIATA)
hops = s.nodeFewestHopsByRegion(obsIATA)
}()
sets := homeRegionSets(counts)
fallback := fallbackRegionSets(hops, sets)
s.nodeHomeRegionSets.Store(&sets)
s.nodeFallbackRegionSets.Store(&fallback)
}
// 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] {
pk := ""
for _, obs := range tx.Observations {
iata := obsIATA[obs.ObserverID]
if iata == "" {
continue
}
if pk == "" {
// Parsed only for adverts heard in some region, and cached on the tx.
if pk = advertPubkey(tx); pk == "" {
break
}
}
h := pathHopCount(obs.PathJSON)
m := per[pk]
if 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.
func pathHopCount(pathJSON string) int {
p := strings.TrimSpace(pathJSON)
if p == "" || p == "[]" || p == "null" {
return 0
}
return strings.Count(p, ",") + 1
}
// fallbackRegionSets places each node without home regions in the regions
// that heard it in the fewest hops, or none (an empty set, so RegionNodePubkeys
// knows not to fall back further) when that is over regionFallbackMaxHops.
func fallbackRegionSets(hops map[string]map[string]int, homes map[string][]string) map[string][]string {
out := make(map[string][]string, len(hops))
for pk, perIATA := range hops {
if _, placed := homes[pk]; placed {
continue
}
fewest := -1
for _, h := range perIATA {
if fewest < 0 || h < fewest {
fewest = h
}
}
regions := []string{}
if fewest <= regionFallbackMaxHops {
for iata, h := range perIATA {
if h == fewest {
regions = append(regions, iata)
}
}
sort.Strings(regions)
}
out[pk] = regions
}
return out
}
// homeRegionSets turns per-region zero-hop counts into each node's sorted set
@@ -138,5 +224,9 @@ func enrichNodeHomeRegions(node map[string]interface{}, regions []string) {
// sorted regions, empty for a node in no region), or an empty map before the
// first compute. Read-only by convention.
func (s *PacketStore) GetNodeFallbackRegionSets() map[string][]string {
return map[string][]string{}
snap := s.nodeFallbackRegionSets.Load()
if snap == nil {
return map[string][]string{}
}
return *snap
}
+37
View File
@@ -213,3 +213,40 @@ func TestRegionFallbackSkipsZeroHopNodes(t *testing.T) {
t.Errorf("pk_far fallback = %v, want [SAT]", fb["pk_far"])
}
}
// BenchmarkNodeFewestHopsByRegion measures the fallback's added recompute work
// at BenchmarkRegionNodePubkeys' scale: 220k adverts from 4,000 nodes, each
// heard by 8 observers across 10 regions over 0-7 hop paths.
func BenchmarkNodeFewestHopsByRegion(b *testing.B) {
regions := []string{"ANR", "BRU", "GNE", "HEP", "KJK", "LGG", "MST", "NRW", "OBL", "OST"}
observers := make(map[string]string, len(regions))
for _, r := range regions {
observers["o-"+r] = r
}
ps := newRegionTestStore(b, observers)
paths := []string{`[]`, `["aa"]`, `["aa","bb"]`, `["aa","bb","cc"]`, `["aa","bb","cc","dd"]`,
`["aa","bb","cc","dd","ee"]`, `["aa","bb","cc","dd","ee","ff"]`, `["aa","bb","cc","dd","ee","ff","11"]`}
txs := make([]*StoreTx, 0, 220000)
for i := 0; i < 220000; i++ {
tx := mkRegionAdvert(i+1, fmt.Sprintf("pk%04d", i%4000))
for j := 0; j < 8; j++ {
tx.Observations = append(tx.Observations, &StoreObs{
ID: (i+1)*10 + j, TransmissionID: i + 1,
ObserverID: "o-" + regions[(i+j*3)%len(regions)], PathJSON: paths[(i+j)%len(paths)],
})
}
txs = append(txs, tx)
}
addToStore(ps, txs...)
obsIATA := ps.observerIATAs()
ps.recomputeNodeHomeRegionSets() // parse each advert's JSON once, as a running server has
b.ResetTimer()
for i := 0; i < b.N; i++ {
ps.mu.RLock()
hops := ps.nodeFewestHopsByRegion(obsIATA)
ps.mu.RUnlock()
if len(hops) == 0 {
b.Fatal("no nodes")
}
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ func componentSchemas() map[string]interface{} {
"unscoped_relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: subset of relay_count_24h that were unscoped floods (route_type FLOOD). A well-configured repeater sets flood.max.unscoped 0, so a non-trivial count flags a base-config problem."},
"last_relayed": str("Repeater/room only: RFC3339 time this node last appeared as a relay hop."),
"declared_regions": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Repeater/room only (#1862): named regions from this node's newest declared-regions answer, spelled as GET /api/scope-audit declaredRegions spells them (leading '#' stripped, '*' wildcard excluded). Empty array: it answered and named no region. Absent: it never answered, this database has no declared-regions source, or the declared-regions lookup failed. Absence is not evidence the node lacks a region."},
"home_regions": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "#1879: IATA codes of the regions whose observers heard this node zero-hop (a direct advert, not relayed), sorted. Present only when zero-hop data placed the node, and then it decides which regions the ?region= filter puts the node in; a node can have several. Absent: no zero-hop advert in the packet store, so the region filter falls back to every region where an observer heard any of its adverts."},
"home_regions": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "#1879: IATA codes of the regions whose observers heard this node zero-hop (a direct advert, not relayed), sorted. Present only when zero-hop data placed the node, and then it decides which regions the ?region= filter puts the node in; a node can have several. Absent: no zero-hop advert in the packet store, so the region filter places the node in the regions whose observers heard it in the fewest hops, or in none when that is more than 3 hops."},
"relay_window_hours": map[string]interface{}{"type": "integer", "description": "Repeater/room only, /api/nodes/{pubkey} detail endpoint only: width (hours) of the relay-activity window the relay_count_* values cover."},
"traffic_share_score": score01("#672 Traffic axis: share of non-advert traffic relayed through this repeater. Repeater/room only."),
"bridge_score": score01("#672 Bridge axis: normalized betweenness centrality (chokepoint importance). Repeater/room only."),
+19 -11
View File
@@ -39,8 +39,10 @@ func advertPubkey(tx *StoreTx) string {
// RegionNodePubkeys returns the pubkeys of nodes in region (comma-separated
// IATA codes, case- and space-insensitive), over the adverts the store holds.
// A node heard zero-hop is in its home regions (#1879, see
// node_home_regions.go); any other node is in a region when at least one of
// its adverts was heard by an observer there. ok is false when the region
// node_home_regions.go); any other node is in the regions that heard it in the
// fewest hops, or none past regionFallbackMaxHops. Only nodes newer than the
// last recompute fall back to the observer-based check: in a region when at
// least one of its adverts was heard by an observer there. ok is false when the region
// names no code, or there is no database to resolve observers from, so the
// caller applies the SQL filter or none.
//
@@ -72,10 +74,11 @@ func (s *PacketStore) RegionNodePubkeys(region string) (keys []string, ok bool)
// Its own mutex and a 30s cache; taken before s.mu, never under it.
inRegion := s.resolveRegionObservers(cacheKey)
// #1879: nodes heard zero-hop are placed by their home regions; the
// observer-based scan below decides only for nodes never heard zero-hop.
// An atomic load, taken under no lock.
// #1879: nodes heard zero-hop are placed by their home regions, the rest
// by their fewest-hop regions; the observer-based scan below decides only
// for nodes newer than the last recompute. Atomic loads, under no lock.
homes := s.GetNodeHomeRegionSets()
fallback := s.GetNodeFallbackRegionSets()
seen := make(map[string]bool, 256)
keys = make([]string, 0, 256)
@@ -99,6 +102,9 @@ func (s *PacketStore) RegionNodePubkeys(region string) (keys []string, ok bool)
if _, placed := homes[pk]; placed {
continue
}
if _, placed := fallback[pk]; placed {
continue
}
seen[pk] = true
keys = append(keys, pk)
}
@@ -108,12 +114,14 @@ func (s *PacketStore) RegionNodePubkeys(region string) (keys []string, ok bool)
for _, c := range codes {
wanted[c] = true
}
for pk, regions := range homes {
for _, r := range regions {
if wanted[r] {
seen[pk] = true
keys = append(keys, pk)
break
for _, sets := range []map[string][]string{homes, fallback} {
for pk, regions := range sets {
for _, r := range regions {
if wanted[r] {
seen[pk] = true
keys = append(keys, pk)
break
}
}
}
}