mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 21:59:08 +00:00
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>
120 lines
3.7 KiB
Go
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)
|
|
}
|