With a Graph Analytical View active, an openCypher multi-hop pattern returns
nothing. SQL MATCH over the same data and the same traversal returns the
correct answer, and the same Cypher query is correct before the view exists.
There is no error and no warning: the caller gets an empty result set that is
indistinguishable from a legitimate "no matches".
Version: 26.8.1-SNAPSHOT (engine jar from our 2026-08-01 build of main).
Reproduction
Self-contained, engine API only, no bindings. Six vertices, six edges, the
default (bidirectional) edge type.
0 -> 1, 0 -> 2, 1 -> 3, 2 -> 4, 1 -> 5, 3 -> 0
2-hop out from vertex 0 reaches {3, 4, 5} over three paths.
import com.arcadedb.database.Database;
import com.arcadedb.database.DatabaseFactory;
import com.arcadedb.query.sql.executor.Result;
import com.arcadedb.query.sql.executor.ResultSet;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Reproduces: with a Graph Analytical View active, an openCypher multi-hop
* pattern emits the right NUMBER of rows but leaves every variable bound by the
* fused chain unset, so projections come back empty.
*
* Pure engine API, no bindings. Run with:
* java --class-path "<arcadedb jars>/*" GavFusedChainRepro.java
*/
public class GavFusedChainRepro {
static final int[][] EDGES = {{0, 1}, {0, 2}, {1, 3}, {2, 4}, {1, 5}, {3, 0}};
static final String P =
"MATCH (p:Person {id: 0})-[:KNOWS]->(a:Person)-[:KNOWS]->(b:Person) ";
static final String SQL2 =
"MATCH {type: Person, as: p, where: (id = 0)}"
+ ".out('KNOWS'){as: a}.out('KNOWS'){as: b} RETURN DISTINCT b.id AS id";
public static void main(String[] args) throws Exception {
final Path dir = Files.createTempDirectory("gavfused");
final String path = dir.resolve("db").toString();
deleteRecursively(new File(path));
Database db = new DatabaseFactory(path).create();
db.command("sql", "CREATE VERTEX TYPE Person");
db.command("sql", "CREATE PROPERTY Person.id INTEGER");
db.command("sql", "CREATE EDGE TYPE KNOWS");
db.command("sql", "CREATE INDEX ON Person (id) UNIQUE");
db.begin();
for (int v = 0; v <= 5; v++)
db.command("sql", "INSERT INTO Person SET id = " + v);
for (int[] e : EDGES)
db.command("sql", "CREATE EDGE KNOWS FROM (SELECT FROM Person WHERE id=" + e[0]
+ ") TO (SELECT FROM Person WHERE id=" + e[1] + ")");
db.commit();
System.out.println("\ngraph: 0->1, 0->2, 1->3, 2->4, 1->5, 3->0");
System.out.println("2-hop out from 0 reaches {3, 4, 5} over 3 paths\n");
System.out.println("--- no view ---");
report(db);
db.command("sql", "CREATE GRAPH ANALYTICAL VIEW v VERTEX TYPES (Person) "
+ "EDGE TYPES (KNOWS) PROPERTIES (id) UPDATE MODE OFF");
final long t0 = System.currentTimeMillis();
while (true) {
final ResultSet rs = db.query("sql",
"SELECT FROM schema:graphAnalyticalViews WHERE name = 'v'");
final Object statusObj = rs.hasNext() ? rs.next().getProperty("status") : null;
final String status = statusObj == null ? null : String.valueOf(statusObj);
if ("READY".equals(status))
break;
if (System.currentTimeMillis() - t0 > 120_000)
throw new IllegalStateException("view never reached READY, status=" + status);
Thread.sleep(100);
}
System.out.println("\n--- view READY (same session) ---");
report(db);
db.close();
db = new DatabaseFactory(path).open();
System.out.println("\n--- after close and reopen ---");
report(db);
db.close();
deleteRecursively(dir.toFile());
}
static void report(final Database db) {
System.out.println(" plan : " + operatorOf(db));
System.out.println(" SQL b.id : " + ids(db, "sql", SQL2)
+ " <- expected [3, 4, 5]");
System.out.println(" Cypher count(*) : " + scalar(db, P + "RETURN count(*) AS c")
+ " <- expected 3");
System.out.println(" Cypher count(DIST b) : " + scalar(db, P + "RETURN count(DISTINCT b) AS c")
+ " <- expected 3");
System.out.println(" Cypher b.id : " + ids(db, "opencypher", P + "RETURN DISTINCT b.id AS id")
+ " <- expected [3, 4, 5]");
System.out.println(" Cypher a.id (mid) : " + ids(db, "opencypher", P + "RETURN DISTINCT a.id AS id")
+ " <- expected [1, 2]");
System.out.println(" Cypher p.id (start) : " + ids(db, "opencypher", P + "RETURN DISTINCT p.id AS id")
+ " <- expected [0]");
// Same pattern, same filter, expressed as WHERE instead of an inline map.
System.out.println(" Cypher WHERE-form : " + ids(db, "opencypher",
"MATCH (p:Person)-[:KNOWS]->(a:Person)-[:KNOWS]->(b:Person) WHERE p.id = 0 "
+ "RETURN DISTINCT b.id AS id") + " <- expected [3, 4, 5]");
}
static String operatorOf(final Database db) {
final ResultSet rs = db.query("opencypher", "EXPLAIN " + P + "RETURN DISTINCT b.id AS id");
if (!rs.hasNext())
return "?";
// Assign to Object first: getProperty is generic, and passing it straight
// into String.valueOf makes javac infer T = char[] and fail at runtime.
final Object planObj = rs.next().getProperty("executionPlan");
final String plan = String.valueOf(planObj);
for (String k : new String[] {"GAVFusedChain", "GAVExpandInto", "GAVExpandAll", "ExpandAll"})
if (plan.contains(k))
return k;
return "?";
}
static List<Object> ids(final Database db, final String lang, final String q) {
final List<Object> out = new ArrayList<>();
final ResultSet rs = db.query(lang, q);
while (rs.hasNext()) {
final Result r = rs.next();
final Object id = r.getProperty("id");
out.add(id);
}
out.sort(Comparator.comparing(String::valueOf));
return out;
}
static Object scalar(final Database db, final String q) {
final ResultSet rs = db.query("opencypher", q);
if (!rs.hasNext())
return "<no row>";
final Object c = rs.next().getProperty("c");
return c;
}
static void deleteRecursively(final File f) {
final File[] kids = f.listFiles();
if (kids != null)
for (File k : kids)
deleteRecursively(k);
f.delete();
Run it with the engine jars on the classpath:
java --class-path "path/to/arcadedb/jars/*" GavFusedChainRepro.java
Output
--- no view ---
plan : ExpandAll
SQL b.id : [3, 4, 5] <- expected [3, 4, 5]
Cypher count(*) : 3 <- expected 3
Cypher count(DIST b) : 3 <- expected 3
Cypher b.id : [3, 4, 5] <- expected [3, 4, 5]
Cypher a.id (mid) : [1, 2] <- expected [1, 2]
Cypher p.id (start) : [0] <- expected [0]
Cypher WHERE-form : [3, 4, 5] <- expected [3, 4, 5]
--- view READY (same session) ---
plan : GAVFusedChain
SQL b.id : [3, 4, 5] <- expected [3, 4, 5]
Cypher count(*) : 0 <- expected 3
Cypher count(DIST b) : 0 <- expected 3
Cypher b.id : [] <- expected [3, 4, 5]
Cypher a.id (mid) : [] <- expected [1, 2]
Cypher p.id (start) : [0] <- expected [0]
Cypher WHERE-form : [3, 4, 5] <- expected [3, 4, 5]
--- after close and reopen ---
(identical to the READY block)
What that says
-
It tracks the operator. Without the view the plan is ExpandAll and
everything is right. As soon as the view reaches READY the plan becomes
GAVFusedChain and the whole pattern yields nothing. Single-hop patterns
plan as GAVExpandAll and stay correct, so it is specifically the fused
multi-hop chain.
-
Only the start variable survives. p.id still returns [0], while
a.id and b.id are empty and both count(*) and count(DISTINCT b) are
0. The pattern is producing no rows at all rather than rows with unbound
variables.
-
WHERE works where an inline property map does not. The identical
pattern written MATCH (p:Person)-[:KNOWS]->(a)-[:KNOWS]->(b) WHERE p.id = 0
returns [3, 4, 5], while MATCH (p:Person {id: 0})... returns []. That
is a usable workaround and probably narrows the search, since it changes
whether the fused chain is fed by a NodeIndexSeek.
EXPLAIN for the failing form:
Physical Plan:
+ GAVFusedChain(p)-[:KNOWS]->(m*)-[:KNOWS]->(g) [provider=v, hops=2, cost=2.50, rows=0]
+ NodeIndexSeek(p:Person) [index=Person[id], id=0, cost=5.00, rows=0]
Both steps estimate rows=0, which may or may not be related.
Narrowed: the start filter is the trigger, not the edge type
A 2x2 on a 60-vertex ring where every vertex has one KNOWS and one LIKES edge to
the same neighbour, so a same-type and a different-type two-hop must return the
same count on correct behaviour:
|
inline {id: 0} filter |
no filter |
| same edge type twice |
GAVFusedChain, count 0, expected 1 |
GAVFusedChain, count 60, correct |
| two different edge types |
GAVFusedChain, count 0, expected 1 |
GAVFusedChain, count 60, correct |
All four fuse. Both unfiltered forms are correct. Both filtered forms return
zero. So repeating an edge type is irrelevant and the discriminator is the
inline start-property filter, which is consistent with the WHERE-clause
rewrite being a workaround: both change whether a NodeIndexSeek feeds the
fused chain.
Without a view all four are correct, so this is not a Cypher semantics question.
Blast radius: 26 Cypher shapes swept, 3 affected
A view is a performance feature, so the same query must return the same rows
with it present and absent. That is a cleaner oracle than the SQL comparison
above, because it needs no translation between languages, so I ran 26 shapes
both ways on one graph (40 vertices of two labels, two edge types, some null
properties).
23 identical. 3 differ, all planned GAVFusedChain, all returning nothing:
| shape |
no view |
view on |
(a {id:0})-[:E]->(b)-[:E]->(c) |
[14, 2, 8, 8] |
[] |
MATCH (a {id:0})-[:E]->(b) MATCH (b)-[:E]->(c) |
[14, 2, 8, 8] |
[] |
(a {id:0})-[:E]->(b)-[:F]->(c) |
[10, 4] |
[] |
The second is worth calling out separately: splitting the pattern across two
MATCH clauses is a natural thing to try when a query misbehaves, and it fuses
and fails identically. The third confirms mixed edge types are affected too.
Unaffected, verified identical with and without the view: variable-length
paths (*1..2, *2..3), OPTIONAL MATCH including the no-match case, WITH
pipelines, WITH plus aggregate, ORDER BY with LIMIT and with SKIP, OR
and NOT predicates, IS NULL and IS NOT NULL, collect, min, DISTINCT,
edge-property filters and projections, both-direction and incoming traversal,
cross-label traversal, single-hop of every kind, and unfiltered multi-hop.
So this looks tightly contained: the fused multi-hop chain fed by a filtered
start, and nothing else in the Cypher surface.
Possibly related: #4180
#4180 (reopening a database with a persisted GraphAnalyticalView can fail
during async restore) is the same neighbourhood: view plus reopen. It is closed,
and I have no evidence the two share a cause, but if the view's restore path is
where you start looking then it is worth a glance alongside this.
Relation to #5306
#5306 had the same visible symptom, openCypher silently returning empty results
on multi-hop patterns with SQL MATCH correct, and was closed on 2026-07-16.
That one was scoped to unidirectional edges plus a view. This reproduces on the
default edge type with no UNIDIRECTIONAL anywhere, and the plan names
GAVFusedChain specifically, so I suspect a distinct code path rather than a
regression of that fix. Flagging the connection rather than assuming it.
Why this one is worse than a slow query
The answer is wrong and silent. Nothing downstream can tell this empty result
from a real one. It also only appears once a view exists, which is an
optimization someone adds deliberately, so the failure mode is "I added a view
to make my graph queries faster and they started returning nothing".
Found with a differential tester that runs the same semantic query as SQL
MATCH, as openCypher, and as plain Python over the same generated graph, so a
third independent computation decides which surface is wrong rather than only
showing that two disagree. Happy to run any variant on request.
With a Graph Analytical View active, an openCypher multi-hop pattern returns
nothing. SQL
MATCHover the same data and the same traversal returns thecorrect answer, and the same Cypher query is correct before the view exists.
There is no error and no warning: the caller gets an empty result set that is
indistinguishable from a legitimate "no matches".
Version:
26.8.1-SNAPSHOT(engine jar from our 2026-08-01 build ofmain).Reproduction
Self-contained, engine API only, no bindings. Six vertices, six edges, the
default (bidirectional) edge type.
2-hop out from vertex 0 reaches
{3, 4, 5}over three paths.Run it with the engine jars on the classpath:
Output
What that says
It tracks the operator. Without the view the plan is
ExpandAllandeverything is right. As soon as the view reaches
READYthe plan becomesGAVFusedChainand the whole pattern yields nothing. Single-hop patternsplan as
GAVExpandAlland stay correct, so it is specifically the fusedmulti-hop chain.
Only the start variable survives.
p.idstill returns[0], whilea.idandb.idare empty and bothcount(*)andcount(DISTINCT b)are0. The pattern is producing no rows at all rather than rows with unboundvariables.
WHEREworks where an inline property map does not. The identicalpattern written
MATCH (p:Person)-[:KNOWS]->(a)-[:KNOWS]->(b) WHERE p.id = 0returns
[3, 4, 5], whileMATCH (p:Person {id: 0})...returns[]. Thatis a usable workaround and probably narrows the search, since it changes
whether the fused chain is fed by a
NodeIndexSeek.EXPLAINfor the failing form:Both steps estimate
rows=0, which may or may not be related.Narrowed: the start filter is the trigger, not the edge type
A 2x2 on a 60-vertex ring where every vertex has one KNOWS and one LIKES edge to
the same neighbour, so a same-type and a different-type two-hop must return the
same count on correct behaviour:
{id: 0}filterGAVFusedChain, count 0, expected 1GAVFusedChain, count 60, correctGAVFusedChain, count 0, expected 1GAVFusedChain, count 60, correctAll four fuse. Both unfiltered forms are correct. Both filtered forms return
zero. So repeating an edge type is irrelevant and the discriminator is the
inline start-property filter, which is consistent with the
WHERE-clauserewrite being a workaround: both change whether a
NodeIndexSeekfeeds thefused chain.
Without a view all four are correct, so this is not a Cypher semantics question.
Blast radius: 26 Cypher shapes swept, 3 affected
A view is a performance feature, so the same query must return the same rows
with it present and absent. That is a cleaner oracle than the SQL comparison
above, because it needs no translation between languages, so I ran 26 shapes
both ways on one graph (40 vertices of two labels, two edge types, some null
properties).
23 identical. 3 differ, all planned
GAVFusedChain, all returning nothing:(a {id:0})-[:E]->(b)-[:E]->(c)[14, 2, 8, 8][]MATCH (a {id:0})-[:E]->(b) MATCH (b)-[:E]->(c)[14, 2, 8, 8][](a {id:0})-[:E]->(b)-[:F]->(c)[10, 4][]The second is worth calling out separately: splitting the pattern across two
MATCHclauses is a natural thing to try when a query misbehaves, and it fusesand fails identically. The third confirms mixed edge types are affected too.
Unaffected, verified identical with and without the view: variable-length
paths (
*1..2,*2..3),OPTIONAL MATCHincluding the no-match case,WITHpipelines,
WITHplus aggregate,ORDER BYwithLIMITand withSKIP,ORand
NOTpredicates,IS NULLandIS NOT NULL,collect,min,DISTINCT,edge-property filters and projections, both-direction and incoming traversal,
cross-label traversal, single-hop of every kind, and unfiltered multi-hop.
So this looks tightly contained: the fused multi-hop chain fed by a filtered
start, and nothing else in the Cypher surface.
Possibly related: #4180
#4180 (reopening a database with a persisted
GraphAnalyticalViewcan failduring async restore) is the same neighbourhood: view plus reopen. It is closed,
and I have no evidence the two share a cause, but if the view's restore path is
where you start looking then it is worth a glance alongside this.
Relation to #5306
#5306 had the same visible symptom, openCypher silently returning empty results
on multi-hop patterns with SQL
MATCHcorrect, and was closed on 2026-07-16.That one was scoped to unidirectional edges plus a view. This reproduces on the
default edge type with no
UNIDIRECTIONALanywhere, and the plan namesGAVFusedChainspecifically, so I suspect a distinct code path rather than aregression of that fix. Flagging the connection rather than assuming it.
Why this one is worse than a slow query
The answer is wrong and silent. Nothing downstream can tell this empty result
from a real one. It also only appears once a view exists, which is an
optimization someone adds deliberately, so the failure mode is "I added a view
to make my graph queries faster and they started returning nothing".
Found with a differential tester that runs the same semantic query as SQL
MATCH, as openCypher, and as plain Python over the same generated graph, so athird independent computation decides which surface is wrong rather than only
showing that two disagree. Happy to run any variant on request.