mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-20 13:25:31 +00:00
eba9e89a72
Red commit: c84a8f575a (CI run: pending
push)
Fixes #1203 — path-inspector 503 storm.
Three sub-fixes, each shipped as red→green per AGENTS TDD:
**A. Singleflight on rebuild** (`ensureNeighborGraph`)
Hand-rolled `sync.Mutex + chan` singleflight — no new deps (x/sync was
not in cmd/server's go.mod). Concurrent callers attach to one in-flight
rebuild instead of N parallel `BuildFromStore` goroutines.
- Red: `7340f23b` — test asserts ≤1 build under 10 concurrent callers
(saw 10 on master)
- Green: `abac6b3c`
**B. Stale-while-revalidate** (`handlePathInspect`)
Stale non-nil graph is served immediately with `"stale": true` while a
background rebuild runs (deduped by A). The 2s synchronous gate is gone.
Stale responses are not cached, so the next request after rebuild lands
fresh.
- Red: `c84a8f57` — test asserts 200+`stale:true`+rebuild-kickoff
(master returned 503)
- Green: `5eb86975`
**C. Cold-start 503 still kicks rebuild**
True cold start (`graph == nil`) is the only path that still returns 503
`{"retry": true}`, but it now spawns an async `ensureNeighborGraph` so
the very next request warms up.
- Green test: `f5ac7059` (passed on top of A+B)
Singleflight verified: `TestEnsureNeighborGraph_Singleflight`
Stale-while-revalidate verified:
`TestHandlePathInspect_StaleWhileRevalidate`
Cold-start verified: `TestHandlePathInspect_ColdStartKicksRebuild`
**Acceptance criteria (issue #1203):**
- [x] Concurrent requests share ONE rebuild
- [x] Stale non-nil graph served with `stale:true` async
- [x] 503 only on true cold-start
- [x] Cold-start 503 kicks rebuild → follow-up warm
- [ ] p99 < 500ms under load (not unit-testable; design satisfies it)
- [x] No regression in existing tests
**Out of scope (per issue):** 5-min TTL constant, `BuildFromStore` perf,
`/api/analytics/topology`, persist-lock contention.
No new deps.
---------
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: corescope-bot <bot@corescope.dev>
311 lines
10 KiB
Go
311 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ─── Unit tests for path inspector (issue #944) ────────────────────────────────
|
|
|
|
func TestScoreHop_EdgeWeight(t *testing.T) {
|
|
store := &PacketStore{}
|
|
graph := NewNeighborGraph()
|
|
now := time.Now()
|
|
|
|
// Add an edge between A and B.
|
|
graph.mu.Lock()
|
|
edge := &NeighborEdge{
|
|
NodeA: "aaaa", NodeB: "bbbb",
|
|
Count: 50, LastSeen: now.Add(-1 * time.Hour),
|
|
Observers: map[string]bool{"obs1": true},
|
|
}
|
|
key := edgeKey{"aaaa", "bbbb"}
|
|
graph.edges[key] = edge
|
|
graph.byNode["aaaa"] = append(graph.byNode["aaaa"], edge)
|
|
graph.byNode["bbbb"] = append(graph.byNode["bbbb"], edge)
|
|
graph.mu.Unlock()
|
|
|
|
entry := beamEntry{pubkeys: []string{"aaaa"}, names: []string{"NodeA"}}
|
|
cand := nodeInfo{PublicKey: "bbbb", Name: "NodeB", Role: "repeater"}
|
|
|
|
score := store.scoreHop(entry, cand, 2, graph, nil, now, 1)
|
|
|
|
// With edge present, edgeScore > 0. With 2 candidates, selectivity = 0.5.
|
|
// Anti-tautology: if we zero out edge weight constant, score would change.
|
|
if score <= 0.05 {
|
|
t.Errorf("expected score > floor, got %f", score)
|
|
}
|
|
|
|
// No edge: score should be lower.
|
|
candNoEdge := nodeInfo{PublicKey: "cccc", Name: "NodeC", Role: "repeater"}
|
|
scoreNoEdge := store.scoreHop(entry, candNoEdge, 2, graph, nil, now, 1)
|
|
if scoreNoEdge >= score {
|
|
t.Errorf("expected no-edge score (%f) < edge score (%f)", scoreNoEdge, score)
|
|
}
|
|
}
|
|
|
|
func TestScoreHop_FirstHop(t *testing.T) {
|
|
store := &PacketStore{}
|
|
graph := NewNeighborGraph()
|
|
now := time.Now()
|
|
|
|
entry := beamEntry{pubkeys: nil, names: nil}
|
|
cand := nodeInfo{PublicKey: "aaaa", Name: "NodeA", Role: "repeater"}
|
|
|
|
score := store.scoreHop(entry, cand, 3, graph, nil, now, 0)
|
|
// First hop: edgeScore=1.0, geoScore=1.0, recencyScore=1.0, selectivity=1/3
|
|
// = 0.35*1 + 0.20*1 + 0.15*1 + 0.30*(1/3) = 0.35+0.20+0.15+0.10 = 0.80
|
|
expected := 0.35 + 0.20 + 0.15 + 0.30/3.0
|
|
if score < expected-0.01 || score > expected+0.01 {
|
|
t.Errorf("expected ~%f, got %f", expected, score)
|
|
}
|
|
}
|
|
|
|
func TestScoreHop_GeoPlausibility(t *testing.T) {
|
|
store := &PacketStore{}
|
|
store.nodeCache = []nodeInfo{
|
|
{PublicKey: "aaaa", Name: "A", Role: "repeater", Lat: 37.0, Lon: -122.0, HasGPS: true},
|
|
{PublicKey: "bbbb", Name: "B", Role: "repeater", Lat: 37.01, Lon: -122.01, HasGPS: true}, // ~1.4km
|
|
{PublicKey: "cccc", Name: "C", Role: "repeater", Lat: 40.0, Lon: -120.0, HasGPS: true}, // ~400km
|
|
}
|
|
store.nodePM = buildPrefixMap(store.nodeCache)
|
|
store.nodeCacheTime = time.Now()
|
|
|
|
graph := NewNeighborGraph()
|
|
now := time.Now()
|
|
|
|
nodeByPK := map[string]*nodeInfo{
|
|
"aaaa": &store.nodeCache[0],
|
|
"bbbb": &store.nodeCache[1],
|
|
"cccc": &store.nodeCache[2],
|
|
}
|
|
|
|
entry := beamEntry{pubkeys: []string{"aaaa"}, names: []string{"A"}}
|
|
|
|
// Close node should score higher than far node (geo component).
|
|
scoreClose := store.scoreHop(entry, store.nodeCache[1], 2, graph, nodeByPK, now, 1)
|
|
scoreFar := store.scoreHop(entry, store.nodeCache[2], 2, graph, nodeByPK, now, 1)
|
|
if scoreFar >= scoreClose {
|
|
t.Errorf("expected far node score (%f) < close node score (%f)", scoreFar, scoreClose)
|
|
}
|
|
}
|
|
|
|
func TestBeamSearch_WidthCap(t *testing.T) {
|
|
store := &PacketStore{}
|
|
graph := NewNeighborGraph()
|
|
graph.builtAt = time.Now()
|
|
now := time.Now()
|
|
|
|
// Create 25 nodes that all match prefix "aa".
|
|
var nodes []nodeInfo
|
|
for i := 0; i < 25; i++ {
|
|
// Each node has pubkey starting with "aa" followed by unique hex.
|
|
pk := "aa" + strings.Repeat("0", 4) + fmt.Sprintf("%02x", i)
|
|
nodes = append(nodes, nodeInfo{PublicKey: pk, Name: pk, Role: "repeater"})
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
|
|
// Two hops of "aa" — should produce 25*25=625 combos, pruned to 20.
|
|
beam := store.beamSearch([]string{"aa", "aa"}, pm, graph, nil, now)
|
|
if len(beam) > beamWidth {
|
|
t.Errorf("beam exceeded width: got %d, want <= %d", len(beam), beamWidth)
|
|
}
|
|
// Anti-tautology: without beam pruning, we'd have up to 25*min(25,beamWidth)=500 entries.
|
|
// The test verifies pruning is effective.
|
|
}
|
|
|
|
func TestBeamSearch_Speculative(t *testing.T) {
|
|
store := &PacketStore{}
|
|
graph := NewNeighborGraph()
|
|
graph.builtAt = time.Now()
|
|
now := time.Now()
|
|
|
|
// Create nodes with no edges and multiple candidates — should result in low scores (speculative).
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "aabb", Name: "N1", Role: "repeater"},
|
|
{PublicKey: "aabb22", Name: "N1b", Role: "repeater"},
|
|
{PublicKey: "ccdd", Name: "N2", Role: "repeater"},
|
|
{PublicKey: "ccdd22", Name: "N2b", Role: "repeater"},
|
|
{PublicKey: "ccdd33", Name: "N2c", Role: "repeater"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
|
|
beam := store.beamSearch([]string{"aa", "cc"}, pm, graph, nil, now)
|
|
if len(beam) == 0 {
|
|
t.Fatal("expected at least one result")
|
|
}
|
|
|
|
// Score should be < 0.7 since there's no edge and multiple candidates (speculative).
|
|
nHops := len(beam[0].pubkeys)
|
|
score := 1.0
|
|
if nHops > 0 {
|
|
product := beam[0].score
|
|
score = pow(product, 1.0/float64(nHops))
|
|
}
|
|
if score >= speculativeThreshold {
|
|
t.Errorf("expected speculative score (< %f), got %f", speculativeThreshold, score)
|
|
}
|
|
}
|
|
|
|
func TestHandlePathInspect_EmptyPrefixes(t *testing.T) {
|
|
srv := newTestServerForInspect(t)
|
|
body := `{"prefixes":[]}`
|
|
rr := doInspectRequest(srv, body)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandlePathInspect_OddLengthPrefix(t *testing.T) {
|
|
srv := newTestServerForInspect(t)
|
|
body := `{"prefixes":["abc"]}`
|
|
rr := doInspectRequest(srv, body)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for odd-length prefix, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandlePathInspect_MixedLengths(t *testing.T) {
|
|
srv := newTestServerForInspect(t)
|
|
body := `{"prefixes":["aa","bbcc"]}`
|
|
rr := doInspectRequest(srv, body)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for mixed lengths, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandlePathInspect_TooLongPrefix(t *testing.T) {
|
|
srv := newTestServerForInspect(t)
|
|
body := `{"prefixes":["aabbccdd"]}`
|
|
rr := doInspectRequest(srv, body)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for >3-byte prefix, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandlePathInspect_TooManyPrefixes(t *testing.T) {
|
|
srv := newTestServerForInspect(t)
|
|
prefixes := make([]string, 65)
|
|
for i := range prefixes {
|
|
prefixes[i] = "aa"
|
|
}
|
|
b, _ := json.Marshal(map[string]interface{}{"prefixes": prefixes})
|
|
rr := doInspectRequest(srv, string(b))
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for >64 prefixes, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandlePathInspect_ValidRequest(t *testing.T) {
|
|
srv := newTestServerForInspect(t)
|
|
|
|
// Seed nodes in the store — multiple candidates per prefix to lower selectivity.
|
|
srv.store.nodeCache = []nodeInfo{
|
|
{PublicKey: "aabb1234", Name: "NodeA", Role: "repeater", Lat: 37.0, Lon: -122.0, HasGPS: true},
|
|
{PublicKey: "aabb5678", Name: "NodeA2", Role: "repeater"},
|
|
{PublicKey: "ccdd5678", Name: "NodeB", Role: "repeater", Lat: 37.01, Lon: -122.01, HasGPS: true},
|
|
{PublicKey: "ccdd9999", Name: "NodeB2", Role: "repeater"},
|
|
{PublicKey: "ccdd1111", Name: "NodeB3", Role: "repeater"},
|
|
}
|
|
srv.store.nodePM = buildPrefixMap(srv.store.nodeCache)
|
|
srv.store.nodeCacheTime = time.Now()
|
|
g := NewNeighborGraph()
|
|
g.builtAt = time.Now()
|
|
srv.store.graph.Store(g)
|
|
|
|
body := `{"prefixes":["aa","cc"]}`
|
|
rr := doInspectRequest(srv, body)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp pathInspectResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("invalid JSON response: %v", err)
|
|
}
|
|
if len(resp.Candidates) == 0 {
|
|
t.Error("expected at least one candidate")
|
|
}
|
|
if resp.Candidates[0].Speculative != true {
|
|
// No edge between nodes, so score should be < 0.7.
|
|
t.Error("expected speculative=true for no-edge path")
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
func newTestServerForInspect(t *testing.T) *Server {
|
|
t.Helper()
|
|
store := &PacketStore{
|
|
inspectCache: make(map[string]*inspectCachedResult),
|
|
}
|
|
g := NewNeighborGraph()
|
|
g.builtAt = time.Now()
|
|
store.graph.Store(g)
|
|
return &Server{store: store}
|
|
}
|
|
|
|
func doInspectRequest(srv *Server, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest("POST", "/api/paths/inspect", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
srv.handlePathInspect(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func pow(base, exp float64) float64 {
|
|
return math.Pow(base, exp)
|
|
}
|
|
|
|
// BenchmarkBeamSearch — performance proof for spec §2.5 (<100ms p99 for ≤64 hops).
|
|
// Anti-tautology: removing beam pruning makes this ~625x slower; timing assertion catches it.
|
|
func BenchmarkBeamSearch(b *testing.B) {
|
|
// Setup: 100 nodes, 10-hop prefix input, realistic neighbor graph.
|
|
// Anti-tautology: removing beam pruning makes this ~625x slower.
|
|
store := &PacketStore{}
|
|
pm := &prefixMap{m: make(map[string][]nodeInfo)}
|
|
graph := NewNeighborGraph()
|
|
nodes := make([]nodeInfo, 100)
|
|
|
|
now := time.Now()
|
|
for i := 0; i < 100; i++ {
|
|
pk := fmt.Sprintf("%064x", i)
|
|
prefix := fmt.Sprintf("%02x", i%256)
|
|
node := nodeInfo{PublicKey: pk, Name: fmt.Sprintf("Node%d", i), Role: "repeater", Lat: 37.0 + float64(i)*0.01, Lon: -122.0 + float64(i)*0.01}
|
|
nodes[i] = node
|
|
pm.m[prefix] = append(pm.m[prefix], node)
|
|
// Add neighbor edges to create a connected graph.
|
|
if i > 0 {
|
|
prevPK := fmt.Sprintf("%064x", i-1)
|
|
key := makeEdgeKey(prevPK, pk)
|
|
edge := &NeighborEdge{NodeA: prevPK, NodeB: pk, LastSeen: now, Count: 10}
|
|
graph.edges[key] = edge
|
|
graph.byNode[prevPK] = append(graph.byNode[prevPK], edge)
|
|
graph.byNode[pk] = append(graph.byNode[pk], edge)
|
|
}
|
|
}
|
|
|
|
// 10-hop input using prefixes that map to multiple candidates.
|
|
prefixes := make([]string, 10)
|
|
for i := 0; i < 10; i++ {
|
|
prefixes[i] = fmt.Sprintf("%02x", (i*3)%256)
|
|
}
|
|
|
|
nodeByPK := make(map[string]*nodeInfo)
|
|
for idx := range nodes {
|
|
nodeByPK[nodes[idx].PublicKey] = &nodes[idx]
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
store.beamSearch(prefixes, pm, graph, nodeByPK, now)
|
|
}
|
|
}
|