mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 18:08:23 +00:00
perf: resolve hops from in-memory prefix map instead of N+1 DB queries (#577)
## Summary Replace N+1 per-hop DB queries in `handleResolveHops` with O(1) lookups against the in-memory prefix map that already exists in the packet store. ## Problem Each hop in the `resolve-hops` API triggered a separate `SELECT ... LIKE ?` query against the nodes table. With 10 hops, that's 10 DB round-trips — unnecessary when `getCachedNodesAndPM()` already maintains an in-memory prefix map that can resolve hops instantly. ## Changes - **routes.go**: Replace the per-hop DB query loop with `pm.m[hopLower]` lookups from the prefix map. Convert `nodeInfo` → `HopCandidate` inline. Remove unused `rows`/`sql.Scan` code. - **store.go**: Add `InvalidateNodeCache()` method to force prefix map rebuild (needed by tests that insert nodes after store initialization). - **routes_test.go**: Give `TestResolveHopsAmbiguous` a proper store so hops resolve via the prefix map. - **resolve_context_test.go**: Call `InvalidateNodeCache()` after inserting test nodes. Fix confidence assertion — with GPS candidates and no affinity context, `resolveWithContext` correctly returns `gps_preference` (previously masked because the prefix map didn't have the test nodes). ## Complexity O(1) per hop lookup via hash map vs O(n) DB scan per hop. No hot-path impact — this endpoint is called on-demand, not in a render loop. Fixes #369 --------- Co-authored-by: you <you@example.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
+16
-15
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user