Files
meshcore-analyzer/cmd/server/neighbor_graph_cache.go
T
efitenandClaude Opus 5.5 efd2aeabb4 perf(neighbors): rebuild the neighbor graph only in the background (#2221) (#2222)
Fixes #2221.

## Problem

`getNeighborGraph` rebuilt `Server.neighborGraph` on the request
goroutine once it passed its 5 min TTL. It held `neighborMu` for the
whole build, which takes 11.1-11.4 s on a production store.

The 5 min recomputer went through the same staleness check. A tick that
landed just before the TTL skipped the rebuild and left it to the next
request. As a result `/api/nodes/{pubkey}/neighbors` ran at p95 10.4 s
and max 11.0 s (57 requests).

## Change

- **`getNeighborGraph`** serves the current graph. It builds only when
there is none yet, which happens at startup.
- **`rebuildNeighborGraph`** builds from the packet store whatever the
age of the current graph, outside `neighborMu`, and then swaps the
pointer.
  - `recomputeNeighborGraphCache` calls it first on every tick.
- Without a store it keeps the current graph, so tests that inject a
graph are unaffected.
- **`neighborBuildMu`** serializes builds, so a startup request and the
first tick do not both build.

Freshness is now bounded by the recompute interval
(`neighborGraph.cacheRecomputeIntervalSeconds`, default 5 min) plus one
build. Before this change it was the 5 min TTL plus one build, whatever
the interval. So the bound is the same only at the default 300:
- with a longer interval (for example 3600), `/api/nodes/{pk}/neighbors`
can serve a graph up to that old, where before it was about 5 min;
- with a shorter interval (for example 30), every tick does a full
build, where before there was at most one build per 5 min.

The config comment and `config.example.json` now say this (commit
`de7ae981`).

`IsStale`/`neighborGraphTTL` stay, because `path_inspect.go` still uses
them for `store.graph`.

## Perf

- **Request path:** after the first build it does no build at all. It
takes one mutex and reads one pointer.
- **Background:** the recompute tick now always builds, once every 5
min. Before, it built only on the ticks that found the graph stale, and
a request built in the other cases.
- **Total:** the number of builds stays about the same.

Before: `/api/perf` on production shows p95 10404 ms. After: the
follow-up comment gives client-side curl timings from staging (40
requests, p95 0.204 s), not `/api/perf`. Staging builds the graph in
1.03 s against 11 s on production, so the two are different instances.

## Tests

`neighbor_graph_rebuild_test.go`:
- a stale graph is served without a rebuild;
- a graph is built when there is none yet;
- a recompute tick replaces a graph that is not yet stale;
- a recompute without a store keeps the injected graph.

The first and third tests fail on master. `go vet` and `go test .` pass
in `cmd/server`, and `-race` passes on the neighbor-graph tests.

No frontend change.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:17:37 +02:00

157 lines
5.0 KiB
Go

package main
import (
"bytes"
"encoding/json"
"log"
"runtime/debug"
"strconv"
"sync/atomic"
"time"
)
// #1481 P0-1: cached default-filter neighbor-graph response.
//
// The /api/analytics/neighbor-graph handler does graph build + per-edge
// score + filter + ~900KB JSON marshal on every request. The default
// (no-region, no-role, minCount=5, minScore=0.1) shape covers the
// overwhelming majority of organic traffic; cache the fully-built AND
// pre-marshaled response so warm reads are a single Write. Recomputed
// every 5 minutes in the background — never on the hot path.
const neighborGraphCacheInterval = 5 * time.Minute
// neighborGraphCacheEntry holds both the response struct (kept for
// tests / structured access) and the pre-marshaled bytes that the
// handler writes verbatim.
type neighborGraphCacheEntry struct {
resp NeighborGraphResponse
json []byte
at time.Time
}
type neighborGraphCacheField struct {
ptr atomic.Pointer[neighborGraphCacheEntry]
// unfiltered = the (minCount=1, minScore=0, no region/role) shape
// the analytics tab actually hits. Cached separately so the UI
// tab also benefits from the warm path; client-side sliders then
// filter from full data. #1483 follow-up to perf claim.
unfilteredPtr atomic.Pointer[neighborGraphCacheEntry]
}
// startNeighborGraphRecomputer launches a background goroutine that
// rebuilds the default-shape response every interval. Returns when
// the stop channel is closed.
func (s *Server) startNeighborGraphRecomputer(interval time.Duration, stop <-chan struct{}) {
if interval <= 0 {
interval = neighborGraphCacheInterval
}
go func() {
s.recomputeNeighborGraphCache()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
s.recomputeNeighborGraphCache()
case <-stop:
return
}
}
}()
}
// recomputeNeighborGraphCache builds and pre-marshals the default-shape
// response and atomically swaps it in. Panic-defensive so a single bad
// rebuild doesn't kill the background goroutine — but logs the panic
// and increments a counter so operators see the failure (#1483 follow-up).
func (s *Server) recomputeNeighborGraphCache() {
defer func() {
if r := recover(); r != nil {
log.Printf("[neighbor-graph-cache] rebuild panic: %v\n%s", r, debug.Stack())
atomic.AddUint64(&s.neighborGraphCacheRebuildFailures, 1)
}
}()
s.rebuildNeighborGraph()
start := time.Now()
resp := s.buildDefaultNeighborGraphResponse()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(resp); err != nil {
log.Printf("[neighbor-graph-cache] marshal error: %v", err)
atomic.AddUint64(&s.neighborGraphCacheRebuildFailures, 1)
return
}
s.neighborGraphCache.ptr.Store(&neighborGraphCacheEntry{
resp: resp,
json: buf.Bytes(),
at: time.Now(),
})
log.Printf("[neighbor-graph-cache] rebuild ok in %v, nodes=%d", time.Since(start), len(resp.Nodes))
// Build + cache the analytics-tab shape (minCount=1, minScore=0).
// This is what the UI actually fetches so it can slider client-side.
// Cached separately so its TTL stays aligned with the default cache.
uStart := time.Now()
uResp := s.computeNeighborGraphResponseDispatch(1, 0, "", "")
var uBuf bytes.Buffer
if err := json.NewEncoder(&uBuf).Encode(uResp); err != nil {
log.Printf("[neighbor-graph-cache] unfiltered marshal error: %v", err)
atomic.AddUint64(&s.neighborGraphCacheRebuildFailures, 1)
return
}
s.neighborGraphCache.unfilteredPtr.Store(&neighborGraphCacheEntry{
resp: uResp,
json: uBuf.Bytes(),
at: time.Now(),
})
log.Printf("[neighbor-graph-cache] unfiltered rebuild ok in %v, nodes=%d", time.Since(uStart), len(uResp.Nodes))
}
// loadNeighborGraphCache returns the cached default response if present.
func (s *Server) loadNeighborGraphCache() (NeighborGraphResponse, bool) {
e := s.neighborGraphCache.ptr.Load()
if e == nil {
return NeighborGraphResponse{}, false
}
return e.resp, true
}
// loadNeighborGraphCacheBytes returns the pre-marshaled JSON for the
// cached default response if present, along with the age of the
// snapshot (zero when no entry is present).
func (s *Server) loadNeighborGraphCacheBytes() ([]byte, time.Duration, bool) {
e := s.neighborGraphCache.ptr.Load()
if e == nil || len(e.json) == 0 {
return nil, 0, false
}
age := time.Duration(0)
if !e.at.IsZero() {
age = time.Since(e.at)
}
return e.json, age, true
}
// loadNeighborGraphCacheBytesUnfiltered returns the pre-marshaled JSON
// for the (minCount=1, minScore=0) cache shape used by the analytics
// tab. #1483 follow-up.
func (s *Server) loadNeighborGraphCacheBytesUnfiltered() ([]byte, time.Duration, bool) {
e := s.neighborGraphCache.unfilteredPtr.Load()
if e == nil || len(e.json) == 0 {
return nil, 0, false
}
age := time.Duration(0)
if !e.at.IsZero() {
age = time.Since(e.at)
}
return e.json, age, true
}
// cacheAgeSecondsHeader formats a time.Duration as integer seconds for
// the X-Cache-Age-Seconds response header.
func cacheAgeSecondsHeader(d time.Duration) string {
if d < 0 {
d = 0
}
return strconv.FormatInt(int64(d/time.Second), 10)
}