Conversation
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Improves performance of index-intersection filtering in SubqueryIterator by avoiding repeated List.contains scans, and adds a focused unit test suite to pin intersection semantics.
Changes:
- Replace per-element
List.containsmembership checks with a precomputedHashSetlookup. - Add
SubqueryIteratorTestto verify intersection behavior (null/empty cases, limits, duplicates, and ordering).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| janusgraph-core/src/main/java/org/janusgraph/graphdb/util/SubqueryIterator.java | Precomputes a Set for intersection membership to reduce intersection cost from O(n·m) to O(n+m). |
| janusgraph-core/src/test/java/org/janusgraph/graphdb/util/SubqueryIteratorTest.java | Adds unit tests covering semantics that could be affected by switching from list membership to set membership. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| when(subQuery.getProfiler()).thenReturn(QueryProfiler.NO_OP); | ||
|
|
||
| final IndexSerializer indexSerializer = mock(IndexSerializer.class); | ||
| when(indexSerializer.query(any(), any(), any())).thenReturn(firstIndexResults.stream()); |
There was a problem hiding this comment.
Applied — it is thenAnswer(invocation -> firstIndexResults.stream()) now, so each call gets a fresh stream.
| //Membership is tested once for every element the first index returns, and otherResults is deliberately | ||
| //unbounded: StandardJanusGraphTx passes NO_LIMIT to processIntersectingRetrievals so that the intersection is | ||
| //complete. Scanning the list for each element would make the intersection cost O(n*m) | ||
| final Set<Object> otherResultSet = otherResults == null ? null : new HashSet<>(otherResults); | ||
| elementIterator = stream | ||
| .filter(e -> otherResults == null || otherResults.contains(e)) | ||
| .filter(e -> otherResultSet == null || otherResultSet.contains(e)) |
There was a problem hiding this comment.
Worth checking, but the requirement is not new here. otherResults is what QueryUtil.processIntersectingRetrievals returns, and for every index after the first it narrows the result with results.removeIf(o -> !subResultSet.contains(o)) against new HashSet<>(subResult). So membership among these ids is already hash based one call earlier. I checked the id types that actually reach this filter as well — Long, String for custom vertex ids, and RelationIdentifier, whose hashCode is Long.hashCode(relationId) while equals compares relationId and typeId, so equal instances always hash equal.
I have recorded the first half of that in the comment above the conversion so the next reader does not have to re-derive it.
| //Membership is tested once for every element the first index returns, and otherResults is deliberately | ||
| //unbounded: StandardJanusGraphTx passes NO_LIMIT to processIntersectingRetrievals so that the intersection is | ||
| //complete. Scanning the list for each element would make the intersection cost O(n*m) | ||
| final Set<Object> otherResultSet = otherResults == null ? null : new HashSet<>(otherResults); |
There was a problem hiding this comment.
No change needed — HashSet(Collection) already does exactly this. From the JDK 11 source:
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}That is otherResults.size() against the 0.75 load factor, which is the sizing you are asking for, so there is no rehashing to avoid.
SubqueryIterator streams the results of the first index and keeps the elements which the other indexes also returned. Membership was tested with List.contains inside the filter, so each streamed element scanned the other list from the start, which costs O(n*m). The list is deliberately unbounded. StandardJanusGraphTx passes Query.NO_LIMIT to processIntersectingRetrievals so that the intersection is complete and no result is missed, which makes subLimit Integer.MAX_VALUE and materialises the whole matching set of every other index. Build the set once outside the filter, which makes the intersection O(n + m). Signed-off-by: Balmukund Trivedi <btrivedipublic@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Oleksandr Porunov <alexandr.porunov@gmail.com> Signed-off-by: Oleksandr Porunov <alexandr.porunov@gmail.com>
9a9a3fb to
47603e1
Compare
|
Reviewed, rebased on current What I verifiedThe reasoning in the issue and the PR body holds up end to end:
On the testsThey pin behaviour rather than timing, which is the right call for this change, and they are not vacuous. I checked by making the conversion carelessly — treating a On the trade-off you raisedDoubling the peak memory of ids that are already materialised, in exchange for removing a quadratic scan over an intentionally unbounded list, is clearly the right side of that trade. I also agree with not folding in the Verification
|
Fixes #4932.
SubqueryIteratorstreams the results of the first index and keeps the elements the other indexes also returned. Membership was tested withList.containsinside the filter, so every streamed element scanned the other list from the start, giving O(n·m).This matters more than a typical
contains-on-a-list nit because the list is deliberately unbounded.StandardJanusGraphTxpassesQuery.NO_LIMITtoprocessIntersectingRetrievals, with a comment explaining that this prevents incomplete intersections and therefore missed results. That makessubLimitInteger.MAX_VALUE, so the whole matching set of every other index is materialised, and the linear scan runs against all of it.Building the set once outside the lambda makes the intersection O(n + m). This is the fix suggested in the issue.
Testing
The change is a performance fix with no intended behaviour change, so
SubqueryIteratorTestpins the behaviour that the conversion must preserve rather than trying to measure time: only intersected ids survive;null(the single-index case) is not treated as an empty intersection; an empty intersection yields nothing; the limit still truncates; a repeated id in the other results is harmless; and the streamed order is preserved regardless of the order the other indexes reported.That last group is worth having because a
Listand aHashSetdiffer in more than lookup cost — the null case and the ordering are exactly what a careless conversion would break.One note on the trade-off
The set is a second structure over ids that are already materialised, so peak memory for that data roughly doubles while the iterator is open. It stays the same order as the list that already exists, and it replaces a quadratic scan, so I think it is clearly worth it.
If you would rather not hold both, the alternative is to have
processIntersectingRetrievalsreturn aLinkedHashSetinstead of anArrayList— it already builds aHashSetinternally for each intersection step, and it has exactly one caller, this one. I did not do that here because it would change the meaning of theresults.size() < limitloop condition when a sub-result contains duplicates, which deserves its own change rather than riding along with this one.For all changes:
master)?For code changes: