Skip to content

Commit 6e6072b

Browse files
fix: replace N+1 loop in findRelatedEndpoints() with single batch Cypher query
Added GraphStore.findEndpointNeighborsBatch() that fetches all endpoint neighbors for a list of node IDs in one MATCH ... WHERE n.id IN $nodeIds query, eliminating up to 50 separate findNeighbors() calls per invocation. QueryService.findRelatedEndpoints() now separates the direct-endpoint pass from the neighbor pass, using the new batch method for the latter. Deduplication and connected_via semantics are preserved. Added 3 unit tests covering: batch usage (verifying findNeighbors is never called), direct endpoint matches, and deduplication. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 11edfd8 commit 6e6072b

3 files changed

Lines changed: 89 additions & 6 deletions

File tree

src/main/java/io/github/randomcodespace/iq/graph/GraphStore.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,31 @@ public List<CodeNode> findIncomingNeighbors(String nodeId) {
273273
Map.of("nodeId", nodeId));
274274
}
275275

276+
/**
277+
* Batch-find all ENDPOINT/WEBSOCKET_ENDPOINT neighbors for a list of node IDs in one query.
278+
* Returns a map of sourceNodeId -> list of endpoint neighbor nodes.
279+
*/
280+
public Map<String, List<CodeNode>> findEndpointNeighborsBatch(List<String> nodeIds) {
281+
Map<String, List<CodeNode>> result = new java.util.LinkedHashMap<>();
282+
if (nodeIds.isEmpty()) return result;
283+
try (Transaction tx = graphDb.beginTx()) {
284+
var queryResult = tx.execute(
285+
"MATCH (n:CodeNode)-[]-(m:CodeNode) "
286+
+ "WHERE n.id IN $nodeIds AND m.kind IN ['ENDPOINT', 'WEBSOCKET_ENDPOINT'] "
287+
+ "RETURN n.id AS sourceId, m",
288+
Map.of("nodeIds", nodeIds));
289+
while (queryResult.hasNext()) {
290+
var row = queryResult.next();
291+
String sourceId = (String) row.get("sourceId");
292+
Object val = row.get("m");
293+
if (val instanceof org.neo4j.graphdb.Node neo4jNode) {
294+
result.computeIfAbsent(sourceId, k -> new ArrayList<>()).add(nodeFromNeo4j(neo4jNode));
295+
}
296+
}
297+
}
298+
return result;
299+
}
300+
276301
public long count() {
277302
try (Transaction tx = graphDb.beginTx()) {
278303
var result = tx.execute("MATCH (n:CodeNode) RETURN count(n) AS cnt");

src/main/java/io/github/randomcodespace/iq/query/QueryService.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,19 +337,24 @@ public Map<String, Object> findRelatedEndpoints(String identifier) {
337337
Set<String> seenIds = new java.util.LinkedHashSet<>();
338338
List<Map<String, Object>> endpoints = new ArrayList<>();
339339

340+
// First pass: collect matches that are themselves endpoints
340341
for (CodeNode match : matches) {
341342
if (match.getKind() == NodeKind.ENDPOINT || match.getKind() == NodeKind.WEBSOCKET_ENDPOINT) {
342343
if (seenIds.add(match.getId())) {
343344
endpoints.add(nodeToMap(match));
344345
}
345346
}
346-
// Check neighbors for connected endpoints
347-
List<CodeNode> neighbors = graphStore.findNeighbors(match.getId());
348-
for (CodeNode neighbor : neighbors) {
349-
if ((neighbor.getKind() == NodeKind.ENDPOINT || neighbor.getKind() == NodeKind.WEBSOCKET_ENDPOINT)
350-
&& seenIds.add(neighbor.getId())) {
347+
}
348+
349+
// Single batched query for all endpoint neighbors (replaces N+1 loop)
350+
List<String> matchIds = matches.stream().map(CodeNode::getId).toList();
351+
Map<String, List<CodeNode>> endpointNeighbors = graphStore.findEndpointNeighborsBatch(matchIds);
352+
for (Map.Entry<String, List<CodeNode>> entry : endpointNeighbors.entrySet()) {
353+
String sourceId = entry.getKey();
354+
for (CodeNode neighbor : entry.getValue()) {
355+
if (seenIds.add(neighbor.getId())) {
351356
Map<String, Object> epMap = nodeToMap(neighbor);
352-
epMap.put("connected_via", match.getId());
357+
epMap.put("connected_via", sourceId);
353358
endpoints.add(epMap);
354359
}
355360
}

src/test/java/io/github/randomcodespace/iq/query/QueryServiceTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,59 @@ void findDeadCodeShouldReturnEmptyWhenAllNodesHaveSemanticEdges() {
502502
assertTrue(deadCode.isEmpty());
503503
}
504504

505+
// --- findRelatedEndpoints ---
506+
507+
@Test
508+
void findRelatedEndpointsShouldUsesBatchQueryInsteadOfNPlusOne() {
509+
var classNode = makeNode("cls:UserService", NodeKind.CLASS, "UserService");
510+
var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers");
511+
when(graphStore.search("UserService", 50)).thenReturn(List.of(classNode));
512+
when(graphStore.findEndpointNeighborsBatch(List.of("cls:UserService")))
513+
.thenReturn(Map.of("cls:UserService", List.of(endpointNode)));
514+
515+
Map<String, Object> result = service.findRelatedEndpoints("UserService");
516+
517+
assertEquals("UserService", result.get("identifier"));
518+
assertEquals(1, result.get("count"));
519+
assertEquals(1, result.get("searched_nodes"));
520+
@SuppressWarnings("unchecked")
521+
List<Map<String, Object>> endpoints = (List<Map<String, Object>>) result.get("endpoints");
522+
assertEquals("ep:getUsers", endpoints.getFirst().get("id"));
523+
assertEquals("cls:UserService", endpoints.getFirst().get("connected_via"));
524+
// Verify no per-node findNeighbors calls were made
525+
verify(graphStore, never()).findNeighbors(anyString());
526+
}
527+
528+
@Test
529+
void findRelatedEndpointsShouldIncludeDirectEndpointMatches() {
530+
var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers");
531+
when(graphStore.search("getUsers", 50)).thenReturn(List.of(endpointNode));
532+
when(graphStore.findEndpointNeighborsBatch(List.of("ep:getUsers"))).thenReturn(Map.of());
533+
534+
Map<String, Object> result = service.findRelatedEndpoints("getUsers");
535+
536+
assertEquals(1, result.get("count"));
537+
@SuppressWarnings("unchecked")
538+
List<Map<String, Object>> endpoints = (List<Map<String, Object>>) result.get("endpoints");
539+
assertEquals("ep:getUsers", endpoints.getFirst().get("id"));
540+
// Direct endpoint matches have no connected_via
541+
assertNull(endpoints.getFirst().get("connected_via"));
542+
}
543+
544+
@Test
545+
void findRelatedEndpointsShouldDeduplicateEndpoints() {
546+
var endpointNode = makeNode("ep:getUsers", NodeKind.ENDPOINT, "getUsers");
547+
// Same endpoint appears as both a direct match and a neighbor
548+
when(graphStore.search("ep", 50)).thenReturn(List.of(endpointNode));
549+
when(graphStore.findEndpointNeighborsBatch(List.of("ep:getUsers")))
550+
.thenReturn(Map.of("ep:getUsers", List.of(endpointNode)));
551+
552+
Map<String, Object> result = service.findRelatedEndpoints("ep");
553+
554+
// Should only appear once
555+
assertEquals(1, result.get("count"));
556+
}
557+
505558
// --- nodeToMap ---
506559

507560
@Test

0 commit comments

Comments
 (0)