Skip to content

openCypher multi-hop patterns return zero rows when planned through GAVFusedChain; SQL MATCH correct on the same data #5746

Description

@tae898

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

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions