Files
meshcore-analyzer/cmd/server/repeater_enrich_bulk.go
T
efiten 981664528e perf(server): serve stale repeater enrich cache instead of inline rebuild (#1272) (#1436)
## Summary

- Removes the TTL-based inline rebuild from `GetRepeaterRelayInfoMap`
and `GetRepeaterUsefulnessScoreMap`
- When the cache is non-nil it is returned immediately, regardless of
age — no more 700ms on-request recompute
- Inline compute is retained only as a nil-cache guard (edge case: tests
without a running recomputer)
- Fixes the stale `// 15s-TTL gate` comment in
`recomputeRepeaterEnrichmentSafe`

**Root cause:** `computeRepeaterRelayInfoMap` runs inline when the TTL
expires, taking ~700ms on a busy instance.
`StartRepeaterEnrichmentRecomputer` (introduced in #1262) already keeps
the cache warm via synchronous prewarm at startup + 5-min ticks, making
the inline path dead code that fires only when the TTL is shorter than
the recomputer interval (e.g. custom `analytics.defaultIntervalSeconds >
600`).

## Test plan

- [ ] `TestGetRepeaterRelayInfoMap_ServesStaleOnTTLExpiry` — regression
guard: stale sentinel is returned without recompute
- [ ] `TestGetRepeaterUsefulnessScoreMap_ServesStaleOnTTLExpiry` — same
for usefulness score map
- [ ] `TestGetRepeaterRelayInfoMap_BuildsWhenNil` — nil-cache fallback
still works
- [ ] Full `-short` suite passes (`go test -short ./...`)

Closes #1272

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:01:58 -07:00

267 lines
7.5 KiB
Go

package main
import (
"strings"
"time"
)
// GetRepeaterRelayInfoMap returns a cached pubkey → RepeaterRelayInfo
// map covering EVERY pubkey that currently appears as a path hop in any
// non-advert StoreTx. This is the bulk equivalent of calling
// GetRepeaterRelayInfo(pk, windowHours) once per node.
//
// Why this exists (issue #1257): handleNodes used to call the per-node
// helper inside a per-page loop. Each call grabbed its own RLock and
// re-parsed FirstSeen on every StoreTx indexed under that pubkey's
// byPathHop entry (plus, when the pubkey was >= 2 hex chars, the 1-byte
// prefix bucket — which on busy networks fans out to almost the whole
// non-advert tx set). For the default top-50 page of hot repeaters this
// burned 50 lock acquisitions and hundreds of thousands of timestamp
// parses per request, dominating /api/nodes latency.
//
// The cached map is keyed by lowercase pubkey/hop key (same shape as
// byPathHop). Lookups should use strings.ToLower(pk).
//
// The cache is refreshed by the background recomputer (every 5 min by
// default). This function never rebuilds inline on a populated cache —
// serving a slightly stale snapshot is always preferable to a 700ms
// on-request rebuild. The only time an inline compute happens is when
// the cache is nil (i.e. before the recomputer's synchronous prewarm
// completes, which can occur in tests without a running recomputer).
func (s *PacketStore) GetRepeaterRelayInfoMap(windowHours float64) map[string]RepeaterRelayInfo {
s.repeaterEnrichMu.Lock()
cached := s.repeaterRelayCache
s.repeaterEnrichMu.Unlock()
if cached != nil {
return cached
}
// Cache is nil — recomputer hasn't prewarmed yet (edge case: tests
// without a running recomputer, or a request racing the initial
// synchronous prewarm). Build once inline; the recomputer takes over.
result := s.computeRepeaterRelayInfoMap(windowHours)
s.repeaterEnrichMu.Lock()
if s.repeaterRelayCache == nil {
s.repeaterRelayCache = result
s.repeaterRelayCacheWin = windowHours
s.repeaterRelayAt = time.Now()
}
cached = s.repeaterRelayCache
s.repeaterEnrichMu.Unlock()
return cached
}
// computeRepeaterRelayInfoMap walks byPathHop once under a single RLock,
// pre-parses every FirstSeen timestamp once (not once-per-pubkey-bucket),
// and emits one RepeaterRelayInfo per hop key.
//
// Time-complexity invariant: O(unique-tx-in-byPathHop + total-key-bucket
// entries). Memory: one map entry per byPathHop key. Both are bounded by
// the same eviction policy that bounds byPathHop itself.
func (s *PacketStore) computeRepeaterRelayInfoMap(windowHours float64) map[string]RepeaterRelayInfo {
s.mu.RLock()
// Snapshot the slices (header copy) so we can release the lock before
// the expensive parse pass. Slice headers point at the live underlying
// arrays but those are append-only-by-id; the worst-case race here is
// that ingest grows a slice we already snapshotted (we miss the new
// tail), which is acceptable for a 15s-TTL status read.
snap := make(map[string][]*StoreTx, len(s.byPathHop))
for k, list := range s.byPathHop {
snap[k] = list
}
// Build a tx-id-keyed pre-parsed cache so the inner loop doesn't
// re-parse the same FirstSeen N times when the same tx is indexed
// under multiple hop keys (very common — every hop on a path indexes
// the tx).
type parsedTx struct {
t time.Time
ok bool
pt int
}
parseCache := make(map[int]parsedTx, 1<<14)
for _, list := range snap {
for _, tx := range list {
if tx == nil {
continue
}
if _, ok := parseCache[tx.ID]; ok {
continue
}
pt := -1
if tx.PayloadType != nil {
pt = *tx.PayloadType
}
t, ok := parseRelayTS(tx.FirstSeen)
parseCache[tx.ID] = parsedTx{t: t, ok: ok, pt: pt}
}
}
s.mu.RUnlock()
now := time.Now().UTC()
cutoff1h := now.Add(-1 * time.Hour)
cutoff24h := now.Add(-24 * time.Hour)
var windowCutoff time.Time
if windowHours > 0 {
windowCutoff = now.Add(-time.Duration(windowHours * float64(time.Hour)))
}
out := make(map[string]RepeaterRelayInfo, len(snap))
for key, list := range snap {
info := RepeaterRelayInfo{WindowHours: windowHours}
// When key looks like a full pubkey (>= 2 hex chars), also fold
// in the matching 1-byte raw-prefix bucket to mirror
// GetRepeaterRelayInfo's behavior. We dedup by tx ID.
var seen map[int]bool
if len(key) >= 2 {
prefix := key[:2]
if prefix != key {
if extra := snap[prefix]; len(extra) > 0 {
seen = make(map[int]bool, len(list)+len(extra))
}
}
}
visit := func(txs []*StoreTx) {
for _, tx := range txs {
if tx == nil {
continue
}
if seen != nil {
if seen[tx.ID] {
continue
}
seen[tx.ID] = true
}
p, ok := parseCache[tx.ID]
if !ok {
continue
}
if p.pt == payloadTypeAdvert {
continue
}
if !p.ok {
continue
}
if p.t.After(cutoff24h) {
info.RelayCount24h++
if p.t.After(cutoff1h) {
info.RelayCount1h++
}
}
if info.LastRelayed == "" || tx.FirstSeen > info.LastRelayed {
info.LastRelayed = tx.FirstSeen
if windowHours > 0 && p.t.After(windowCutoff) {
info.RelayActive = true
} else if windowHours > 0 {
info.RelayActive = false
}
}
}
}
visit(list)
if seen != nil {
prefix := key[:2]
if prefix != key {
visit(snap[prefix])
}
}
out[key] = info
}
return out
}
// GetRepeaterUsefulnessScoreMap returns a cached pubkey → 0..1 score
// for every pubkey appearing in byPathHop. Bulk equivalent of
// GetRepeaterUsefulnessScore. See GetRepeaterRelayInfoMap for the
// motivation (#1257) and the no-inline-rebuild rationale (#1272).
func (s *PacketStore) GetRepeaterUsefulnessScoreMap() map[string]float64 {
s.repeaterEnrichMu.Lock()
cached := s.repeaterUsefulCache
s.repeaterEnrichMu.Unlock()
if cached != nil {
return cached
}
result := s.computeRepeaterUsefulnessScoreMap()
s.repeaterEnrichMu.Lock()
if s.repeaterUsefulCache == nil {
s.repeaterUsefulCache = result
s.repeaterUsefulAt = time.Now()
}
cached = s.repeaterUsefulCache
s.repeaterEnrichMu.Unlock()
return cached
}
func (s *PacketStore) computeRepeaterUsefulnessScoreMap() map[string]float64 {
s.mu.RLock()
defer s.mu.RUnlock()
totalNonAdvert := 0
for pt, list := range s.byPayloadType {
if pt == payloadTypeAdvert {
continue
}
totalNonAdvert += len(list)
}
out := make(map[string]float64, len(s.byPathHop))
if totalNonAdvert == 0 {
return out
}
denom := float64(totalNonAdvert)
for key, list := range s.byPathHop {
relayed := 0
for _, tx := range list {
if tx == nil {
continue
}
if tx.PayloadType != nil && *tx.PayloadType == payloadTypeAdvert {
continue
}
relayed++
}
if relayed == 0 {
continue
}
score := float64(relayed) / denom
if score < 0 {
score = 0
} else if score > 1 {
score = 1
}
out[key] = score
}
return out
}
// lookupRelayInfo is a small helper to make handleNodes' map lookup
// case-insensitive (byPathHop keys are lowercase; pubkeys arriving from
// the DB row may be either case).
func lookupRelayInfo(m map[string]RepeaterRelayInfo, pubkey string) (RepeaterRelayInfo, bool) {
if v, ok := m[pubkey]; ok {
return v, true
}
if lc := strings.ToLower(pubkey); lc != pubkey {
if v, ok := m[lc]; ok {
return v, true
}
}
return RepeaterRelayInfo{}, false
}
// lookupUsefulnessScore mirrors lookupRelayInfo for the score map.
func lookupUsefulnessScore(m map[string]float64, pubkey string) float64 {
if v, ok := m[pubkey]; ok {
return v
}
if lc := strings.ToLower(pubkey); lc != pubkey {
if v, ok := m[lc]; ok {
return v
}
}
return 0
}