mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 13:37:50 +00:00
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>
157 lines
5.0 KiB
Go
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)
|
|
}
|