Files
meshcore-analyzer/cmd/server/region_nodes.go
T
efitenandClaude Opus 5.5 4363403495 fix(nodes): match the region node filter on observer ID, not ingest-time IATA (#2115)
Follow-up to #2114, from its review.

`RegionNodePubkeys` compared the IATA copied onto each observation at
ingest (`StoreObs.ObserverIATA`). When an operator changes an observer's
code, those copies stay as they were until a restart or until the
observations age out. `/api/nodes?region=` then kept matching the old
region, while the other store region filters already used the new one.
Those filters resolve observers by ID through `resolveRegionObservers`,
for example the packets query and `computeNodeHomeRegions`. The SQL
subquery that #2114 replaced also read `observers.iata` at query time,
so this restores that behaviour.

## Also fixes a regression from #2114

Found in review: `loadChunk`, the background history loader
(`cmd/server/store.go`, the chunk SELECT and the `StoreObs` it builds),
sets `ObserverID` on each observation but never `ObserverIATA`. With
#2114 matching on that IATA, **every node whose adverts came only from
background-loaded history dropped out of `/api/nodes?region=`** on
current master. Matching on observer ID fixes it.
`TestRegionNodePubkeysMatchesChunkLoadedObservations` pins it (an
observation with the observer ID and an empty IATA) and fails on
master's `region_nodes.go`.

## Change

- Resolve the region to observer IDs with `resolveRegionObservers` (own
mutex, 30 s cache) and match observations by `ObserverID`. Lock order:
`regionNodesMu` is released before it, and `s.mu` is taken after it;
none of the three is held together.
- Without a database there is nothing to resolve, so `RegionNodePubkeys`
reports no set and the handler keeps the SQL path.

## Tests

- The region tests now seed an observers table and leave each
observation's IATA at a stale value, so they can only pass through the
table.
- New `TestRegionNodePubkeysFollowsObserverIATAChange`: an observer that
moved from SJC to SFO matches SFO and not SJC. It fails on the previous
code (`got [pk_moved]` for SJC).
- `TestRegionNodePubkeysMatchesChunkLoadedObservations` (second commit),
see above.
- `setupTestDB` takes `testing.TB` so the benchmark can use it; every
existing caller passes `*testing.T` unchanged.
- Full `cmd/server` suite passes locally, the region tests also with
`-race`.

## Performance

`BenchmarkRegionNodePubkeys` (220k adverts × 8 observations): 37 ms to
30 ms per uncached scan, a map lookup per observation instead of a
string normalisation. The observer lookup is one query on the small
`observers` table, cached for 30 s.

## Not done

- No singleflight on a cold cache, and the 64-entry cache still resets
when full. Both were non-blocking in the #2114 review.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:42:07 +02:00

120 lines
3.7 KiB
Go

package main
import (
"strings"
"time"
)
// regionNodesTTL bounds how stale a region's node set may be. Nodes join a
// region at advert cadence, so 30s matches the other region and area caches.
const regionNodesTTL = 30 * time.Second
// regionNodesCacheMax bounds the cache. Its keys come from the client's
// region parameter, so without a bound a client could grow it at will; a
// deployment has a handful of real regions.
const regionNodesCacheMax = 64
type regionNodesEntry struct {
keys []string
at time.Time
}
// advertPubkey returns the originator pubkey of an ADVERT transmission, or ""
// for anything else or an advert whose decoded JSON carries none.
func advertPubkey(tx *StoreTx) string {
if tx == nil || 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 v
}
v, _ := d["public_key"].(string)
return v
}
// RegionNodePubkeys returns the pubkeys of nodes with at least one advert heard
// by an observer in region (comma-separated IATA codes, case- and
// space-insensitive), over the adverts the store holds. 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.
//
// Observers are matched by ID through resolveRegionObservers, i.e. against
// the observers table as it is now, like every other store region filter.
// The IATA copied onto each observation at ingest goes stale when an operator
// changes an observer's code.
//
// #2101: this replaces a SQL subquery that joined every advert to all of its
// observations. On a 16.5M-observation database one such count took 155s,
// and /api/nodes ran it twice per request.
//
// Lock order: regionNodesMu and s.mu are never held together. The scan takes
// s.mu (read) on its own, so it nests under nothing (see the ordering note at
// the top of store.go).
func (s *PacketStore) RegionNodePubkeys(region string) (keys []string, ok bool) {
codes := normalizeRegionCodes(region)
if len(codes) == 0 || s.db == nil {
return nil, false
}
cacheKey := strings.Join(codes, ",")
s.regionNodesMu.Lock()
if e, hit := s.regionNodesCache[cacheKey]; hit && time.Since(e.at) < regionNodesTTL {
s.regionNodesMu.Unlock()
return e.keys, true
}
s.regionNodesMu.Unlock()
// Its own mutex and a 30s cache; taken before s.mu, never under it.
inRegion := s.resolveRegionObservers(cacheKey)
seen := make(map[string]bool, 256)
keys = make([]string, 0, 256)
s.mu.RLock()
for _, tx := range s.byPayloadType[PayloadADVERT] {
heard := false
for _, obs := range tx.Observations {
if inRegion[obs.ObserverID] {
heard = true
break
}
}
if !heard {
continue
}
// Parsed only for adverts heard in the region, and cached on the tx.
if pk := advertPubkey(tx); pk != "" && !seen[pk] {
seen[pk] = true
keys = append(keys, pk)
}
}
s.mu.RUnlock()
s.regionNodesMu.Lock()
if len(s.regionNodesCache) >= regionNodesCacheMax {
for k, e := range s.regionNodesCache {
if time.Since(e.at) >= regionNodesTTL {
delete(s.regionNodesCache, k)
}
}
if len(s.regionNodesCache) >= regionNodesCacheMax {
s.regionNodesCache = make(map[string]regionNodesEntry, regionNodesCacheMax)
}
}
s.regionNodesCache[cacheKey] = regionNodesEntry{keys: keys, at: time.Now()}
s.regionNodesMu.Unlock()
return keys, true
}
// regionNodeKeys resolves a region query parameter through the packet store.
// ok is false when there is no store or the region names no code.
func (s *Server) regionNodeKeys(region string) ([]string, bool) {
if s.store == nil {
return nil, false
}
return s.store.RegionNodePubkeys(region)
}