diff --git a/cmd/server/resolve_context_test.go b/cmd/server/resolve_context_test.go index 6d7af222..00ddefee 100644 --- a/cmd/server/resolve_context_test.go +++ b/cmd/server/resolve_context_test.go @@ -166,6 +166,7 @@ func TestResolveHopsAPI_UniquePrefix(t *testing.T) { // Insert a unique node srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)", "ff11223344", "UniqueNode", 37.0, -122.0) + srv.store.InvalidateNodeCache() req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil) rr := httptest.NewRecorder() @@ -192,6 +193,7 @@ func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) { "ee1aaaaaaa", "Node-E1", 37.0, -122.0) srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon) VALUES (?, ?, ?, ?)", "ee1bbbbbbb", "Node-E2", 38.0, -121.0) + srv.store.InvalidateNodeCache() req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil) rr := httptest.NewRecorder() @@ -204,8 +206,10 @@ func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) { if hr == nil { t.Fatal("expected hop in resolved map") } - if hr.Confidence != "ambiguous" { - t.Fatalf("expected ambiguous, got %s", hr.Confidence) + // With both candidates having GPS and no affinity context, the resolver + // picks the GPS-preferred candidate → confidence is "gps_preference". + if hr.Confidence != "gps_preference" { + t.Fatalf("expected gps_preference, got %s", hr.Confidence) } if len(hr.Candidates) != 2 { t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates)) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 591b2ae1..1f7f7723 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -1419,24 +1419,25 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) { continue } hopLower := strings.ToLower(hop) - rows, err := s.db.conn.Query("SELECT public_key, name, lat, lon FROM nodes WHERE LOWER(public_key) LIKE ?", hopLower+"%") - if err != nil { - resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "ambiguous"} - continue - } + // Resolve candidates from the in-memory prefix map instead of + // issuing per-hop DB queries (fixes N+1 pattern, see #369). var candidates []HopCandidate - for rows.Next() { - var pk string - var name sql.NullString - var lat, lon sql.NullFloat64 - rows.Scan(&pk, &name, &lat, &lon) - candidates = append(candidates, HopCandidate{ - Name: nullStr(name), Pubkey: pk, - Lat: nullFloat(lat), Lon: nullFloat(lon), - }) + if pm != nil { + if matched, ok := pm.m[hopLower]; ok { + for _, ni := range matched { + c := HopCandidate{Pubkey: ni.PublicKey} + if ni.Name != "" { + c.Name = ni.Name + } + if ni.HasGPS { + c.Lat = ni.Lat + c.Lon = ni.Lon + } + candidates = append(candidates, c) + } + } } - rows.Close() if len(candidates) == 0 { resolved[hop] = &HopResolution{Name: nil, Candidates: []HopCandidate{}, Conflicts: []interface{}{}, Confidence: "no_match"} diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index cceda17f..b9a10a8e 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -1170,6 +1170,11 @@ func TestResolveHopsAmbiguous(t *testing.T) { cfg := &Config{Port: 3000} hub := NewHub() srv := NewServer(db, cfg, hub) + store := NewPacketStore(db, nil) + if err := store.Load(); err != nil { + t.Fatalf("store.Load failed: %v", err) + } + srv.store = store router := mux.NewRouter() srv.RegisterRoutes(router) diff --git a/cmd/server/store.go b/cmd/server/store.go index 3d99e0e7..44455945 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -3726,6 +3726,15 @@ func (s *PacketStore) getCachedNodesAndPM() ([]nodeInfo, *prefixMap) { return nodes, pm } +// InvalidateNodeCache forces the next getCachedNodesAndPM call to rebuild. +func (s *PacketStore) InvalidateNodeCache() { + s.cacheMu.Lock() + s.nodeCache = nil + s.nodePM = nil + s.nodeCacheTime = time.Time{} + s.cacheMu.Unlock() +} + func (pm *prefixMap) resolve(hop string) *nodeInfo { h := strings.ToLower(hop) candidates := pm.m[h]