Skip to content

Commit 9a0d5d1

Browse files
authored
fix(#4645): shortestPath edge:true ClassCastException + wrong vertex in BOTH direction (#4646)
1 parent 2370813 commit 9a0d5d1

4 files changed

Lines changed: 197 additions & 5 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Fix #4645 - shortestPath edge:true ClassCastException on BOTH direction with empty side
2+
3+
## Issue
4+
5+
`shortestPath(..., {'direction':'BOTH','edge':true})` throws `ClassCastException` when one of the two directional sub-queries returns an empty edge list.
6+
7+
## Root Cause (two bugs)
8+
9+
**Bug 1 - ClassCastException:** `EdgeToVertexIterable.iterator()` unconditionally cast the result of `edges.iterator()` to `EdgeIterator`:
10+
```java
11+
return new EdgeToVertexIterator((EdgeIterator) edges.iterator(), direction);
12+
```
13+
When `direction:BOTH`, `SQLFunctionShortestPath.getVerticesAndEdges()` recurses into `OUT` and `IN`. For a vertex that has only an outgoing edge, the `IN` side returns `GraphEngine.EMPTY_EDGE_LIST`. That list's `iterator()` returns `Collections.emptyIterator()` which is NOT an `EdgeIterator`, causing `ClassCastException`.
14+
15+
**Bug 2 - Wrong vertex returned:** `EdgeToVertexIterator.next()` called `edge.getVertex(direction)` where `direction` is the traversal direction. For an OUT traversal from vertex 'a', this returns 'a' (the OUT/source end) rather than the neighbor 'b' (the IN/destination end). The BFS made no progress because every "neighbor" was the current vertex itself.
16+
17+
## Fix
18+
19+
**EdgeToVertexIterable:** check `instanceof ResettableIterator` (the common supertype of `EdgeIterator` and `EdgeIteratorFilter`) before casting. `EMPTY_EDGE_LIST.iterator()` returns `Collections.emptyIterator()` which is not a `ResettableIterator`, so it returns an empty iterator instead.
20+
21+
**EdgeToVertexIterator:** change field type to `ResettableIterator<Edge>` and fix `next()` to use the opposite direction (`direction == OUT ? IN : OUT`), which correctly returns the neighbor vertex.
22+
23+
## Files Changed
24+
25+
- `engine/src/main/java/com/arcadedb/graph/EdgeToVertexIterable.java` - guard against non-ResettableIterator (empty edge list)
26+
- `engine/src/main/java/com/arcadedb/graph/EdgeToVertexIterator.java` - accept `ResettableIterator<Edge>`; fix `next()` to return the opposite-end vertex
27+
- `engine/src/test/java/com/arcadedb/function/sql/graph/SQLFunctionShortestPathTest.java` - regression test for `edge:true` + `direction:BOTH` with asymmetric edges
28+
29+
## Verification
30+
31+
Run: `mvn test -pl engine -Dtest=SQLFunctionShortestPathTest`
32+
33+
Results: 14 tests run, 0 failures, 0 errors.
34+
35+
## PR
36+
37+
https://github.com/ArcadeData/arcadedb/pull/4646
38+
39+
## Review cycles
40+
41+
### Cycle 1 - HEAD 73b365f4 (gemini-code-assist review + claude review)
42+
43+
Applied:
44+
- Gemini (high): `EdgeToVertexIterable` now throws `IllegalArgumentException` for a non-empty, non-resettable iterator instead of silently returning an empty iterator (with explanatory comment - also subsumes Claude's "add a comment" suggestion).
45+
- Claude: extracted the direction flip in `EdgeToVertexIterator.next()` to a `neighborEnd` local variable.
46+
- Claude: regression test now asserts the middle element equals the edge RID.
47+
- Claude: added `edgeTrueDirectionBothReverseAsymmetric` (search b->a, empty OUT side exercises the IN half).
48+
- Claude: added `edgeTrueDirectionIn` (pure IN direction with edge:true).
49+
50+
Skipped with rationale:
51+
- Claude suggested removing this `docs/4645-*.md` file. Kept it: the `resolve-issue` workflow creates this tracking doc by design and folds review-cycle history into it. The bot is unaware of this committed convention.
52+
53+
### Cycle 2 - HEAD 705af13ba
54+
55+
- claude-review workflow re-ran on 705af13ba and completed clean (no new actionable comments).
56+
- gemini's consumer bot (being sunset) did not auto re-trigger on the new SHA; its single cycle-1 inline concern (silent-swallow on `EdgeToVertexIterable`) was already resolved in cycle 1 and a resolution reply was posted in the thread.
57+
- No code changes required.
58+
59+
## CI status triage (HEAD 705af13ba)
60+
61+
7 CI test failures, all confirmed unrelated to this change (which only touches the shortestPath edge-to-vertex traversal path):
62+
63+
Pre-existing on main (HEAD 898aebe60, verified by running on a clean main checkout):
64+
- `LockFilesInOrderFileMigrationTest.lockFilesInOrderThrowsWithMigrationMessageWhenFileMigratedByCompaction`
65+
- `SQLVectorDatabaseFunctionsTest.phase6VectorStatistics`
66+
- `SQLFunctionSearchFieldsMoreTest.nonExistentRID`
67+
68+
Flaky in CI (pass deterministically locally on this branch):
69+
- `DatabaseRIDTest.bareRidThrowsWhenNoActiveDatabaseContext`
70+
- `LSMVectorIndexRebuildTest.timerShouldResetOnNewMutations`
71+
- `QueryEngineManagerPoolTest.submittedTasksRunOnPoolThread`
72+
- `Issue4510ForceApplyPartialDeltaTest.forceApplyFullPageOverVersionGapIsApplied`
73+
74+
Change-specific verification: `SQLFunctionShortestPathTest` (14 tests) and the broader graph suite (342 tests) all pass locally.
75+
76+
## Final state
77+
78+
clean-approval - all actionable bot feedback addressed; cycle-2 re-review clean; CI failures triaged as pre-existing/flaky and unrelated. Merge is the developer's decision.

engine/src/main/java/com/arcadedb/graph/EdgeToVertexIterable.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
*/
1919
package com.arcadedb.graph;
2020

21+
import com.arcadedb.utility.ResettableIterator;
22+
23+
import java.util.Collections;
2124
import java.util.Iterator;
2225

2326
/**
@@ -34,6 +37,14 @@ public EdgeToVertexIterable(final Iterable<Edge> edges, final Vertex.DIRECTION d
3437

3538
@Override
3639
public Iterator<Vertex> iterator() {
37-
return new EdgeToVertexIterator((EdgeIterator) edges.iterator(), direction);
40+
final Iterator<Edge> iter = edges.iterator();
41+
if (iter instanceof ResettableIterator)
42+
return new EdgeToVertexIterator((ResettableIterator<Edge>) iter, direction);
43+
44+
// The only non-ResettableIterator expected here is GraphEngine.EMPTY_EDGE_LIST's Collections.emptyIterator().
45+
// An empty iterator is safe to map to an empty result; a non-empty one would be silently dropped, so fail loudly.
46+
if (!iter.hasNext())
47+
return Collections.emptyIterator();
48+
throw new IllegalArgumentException("The edges iterator must be an instance of ResettableIterator when not empty");
3849
}
3950
}

engine/src/main/java/com/arcadedb/graph/EdgeToVertexIterator.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
* Created by luigidellaquila on 02/07/16.
2525
*/
2626
public class EdgeToVertexIterator implements ResettableIterator<Vertex> {
27-
private final EdgeIterator edgeIterator;
28-
private final Vertex.DIRECTION direction;
27+
private final ResettableIterator<Edge> edgeIterator;
28+
private final Vertex.DIRECTION direction;
2929

30-
public EdgeToVertexIterator(final EdgeIterator iterator, final Vertex.DIRECTION direction) {
30+
public EdgeToVertexIterator(final ResettableIterator<Edge> iterator, final Vertex.DIRECTION direction) {
3131
if (direction == Vertex.DIRECTION.BOTH)
3232
throw new IllegalArgumentException("edge to vertex iterator does not support BOTH as direction");
3333

@@ -42,7 +42,9 @@ public boolean hasNext() {
4242

4343
@Override
4444
public Vertex next() {
45-
return edgeIterator.next().getVertex(direction);
45+
// The neighbor sits at the opposite end of the edge from the traversal direction.
46+
final Vertex.DIRECTION neighborEnd = direction == Vertex.DIRECTION.OUT ? Vertex.DIRECTION.IN : Vertex.DIRECTION.OUT;
47+
return edgeIterator.next().getVertex(neighborEnd);
4648
}
4749

4850
@Override

engine/src/test/java/com/arcadedb/function/sql/graph/SQLFunctionShortestPathTest.java

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,107 @@ void consolidatedOptionsMap() throws Exception {
203203
});
204204
}
205205

206+
@Test
207+
void edgeTrueDirectionBothWithAsymmetricEdges() throws Exception {
208+
TestHelper.executeInNewDatabase("testEdgeBothAsymmetric", graph -> {
209+
final MutableVertex[] verts = new MutableVertex[2];
210+
final RID[] edgeRid = new RID[1];
211+
212+
graph.transaction(() -> {
213+
graph.getSchema().createVertexType("BugSP_V");
214+
graph.getSchema().createEdgeType("BugSP_E");
215+
216+
verts[0] = graph.newVertex("BugSP_V").set("name", "a").save();
217+
verts[1] = graph.newVertex("BugSP_V").set("name", "b").save();
218+
edgeRid[0] = verts[0].newEdge("BugSP_E", verts[1]).getIdentity();
219+
});
220+
221+
function = new SQLFunctionShortestPath();
222+
223+
final Map<String, Object> options = new HashMap<>();
224+
options.put("direction", "BOTH");
225+
options.put("edge", true);
226+
227+
final List<RID> result = function.execute(null, null, null, new Object[] { verts[0], verts[1], options },
228+
new BasicCommandContext());
229+
230+
// expected: [a-rid, edge-rid, b-rid]
231+
assertThat(result).hasSize(3);
232+
assertThat(result.getFirst()).isEqualTo(verts[0].getIdentity());
233+
assertThat(result.get(1)).isEqualTo(edgeRid[0]);
234+
assertThat(result.getLast()).isEqualTo(verts[1].getIdentity());
235+
});
236+
}
237+
238+
@Test
239+
void edgeTrueDirectionBothReverseAsymmetric() throws Exception {
240+
// Mirror of edgeTrueDirectionBothWithAsymmetricEdges: search from the destination back to the source,
241+
// so the OUT side of the start vertex is empty and the IN half of the fix is exercised.
242+
TestHelper.executeInNewDatabase("testEdgeBothReverseAsymmetric", graph -> {
243+
final MutableVertex[] verts = new MutableVertex[2];
244+
final RID[] edgeRid = new RID[1];
245+
246+
graph.transaction(() -> {
247+
graph.getSchema().createVertexType("BugSP_V");
248+
graph.getSchema().createEdgeType("BugSP_E");
249+
250+
verts[0] = graph.newVertex("BugSP_V").set("name", "a").save();
251+
verts[1] = graph.newVertex("BugSP_V").set("name", "b").save();
252+
edgeRid[0] = verts[0].newEdge("BugSP_E", verts[1]).getIdentity();
253+
});
254+
255+
function = new SQLFunctionShortestPath();
256+
257+
final Map<String, Object> options = new HashMap<>();
258+
options.put("direction", "BOTH");
259+
options.put("edge", true);
260+
261+
// search b -> a
262+
final List<RID> result = function.execute(null, null, null, new Object[] { verts[1], verts[0], options },
263+
new BasicCommandContext());
264+
265+
// expected: [b-rid, edge-rid, a-rid]
266+
assertThat(result).hasSize(3);
267+
assertThat(result.getFirst()).isEqualTo(verts[1].getIdentity());
268+
assertThat(result.get(1)).isEqualTo(edgeRid[0]);
269+
assertThat(result.getLast()).isEqualTo(verts[0].getIdentity());
270+
});
271+
}
272+
273+
@Test
274+
void edgeTrueDirectionIn() throws Exception {
275+
// Pure IN traversal with edge:true: from b, follow incoming edges back to a.
276+
TestHelper.executeInNewDatabase("testEdgeDirectionIn", graph -> {
277+
final MutableVertex[] verts = new MutableVertex[2];
278+
final RID[] edgeRid = new RID[1];
279+
280+
graph.transaction(() -> {
281+
graph.getSchema().createVertexType("BugSP_V");
282+
graph.getSchema().createEdgeType("BugSP_E");
283+
284+
verts[0] = graph.newVertex("BugSP_V").set("name", "a").save();
285+
verts[1] = graph.newVertex("BugSP_V").set("name", "b").save();
286+
edgeRid[0] = verts[0].newEdge("BugSP_E", verts[1]).getIdentity();
287+
});
288+
289+
function = new SQLFunctionShortestPath();
290+
291+
final Map<String, Object> options = new HashMap<>();
292+
options.put("direction", "IN");
293+
options.put("edge", true);
294+
295+
// a -OUT-> b, so from b the IN edge leads to a
296+
final List<RID> result = function.execute(null, null, null, new Object[] { verts[1], verts[0], options },
297+
new BasicCommandContext());
298+
299+
// expected: [b-rid, edge-rid, a-rid]
300+
assertThat(result).hasSize(3);
301+
assertThat(result.getFirst()).isEqualTo(verts[1].getIdentity());
302+
assertThat(result.get(1)).isEqualTo(edgeRid[0]);
303+
assertThat(result.getLast()).isEqualTo(verts[0].getIdentity());
304+
});
305+
}
306+
206307
@Test
207308
void rejectsUnknownOption() throws Exception {
208309
TestHelper.executeInNewDatabase("testShortestPathUnknownOption", graph -> {

0 commit comments

Comments
 (0)