Found while working on #6289 (PR #6298) and verified with runnable reproductions before filing. Not a performance question like #6289 was - algo.steinerTree returns wrong trees and wrong weights, and it returns different ones depending on whether a Graph Analytical View happens to be present.
Root cause
AlgoSteinerTree.buildWeightedAdj pairs weights with neighbours by position:
final int[][] adj = graph.adjacency(Vertex.DIRECTION.BOTH, relTypes); // built WITH relTypes
final double[][] adjW = buildWeightedAdj(graph, adj, weightProperty);
...
private double[][] buildWeightedAdj(...) {
for (int i = 0; i < n; i++) {
final Iterable<Edge> edges = graph.getVertex(i).getEdges(Vertex.DIRECTION.BOTH); // NO relTypes
final double[] tmpW = new double[adj[i].length];
int pos = 0;
for (final Edge e : edges) {
...
if (pos < adj[i].length) {
tmpW[pos] = ...; // the pos-th EDGE's weight lands on the pos-th FILTERED NEIGHBOUR
pos++;
}
}
adjW[i] = tmpW;
}
}
adj[i] is the neighbour list after the relTypes filter and in whatever order the backing store produced. tmpW is filled from an unfiltered getEdges(BOTH) walk in OLTP order. Nothing reconciles the two - nbIdx is computed and then only used as a skip test, never as the index to write at. So adjW[i][j] is the weight of "the j-th edge I happened to iterate", not "the weight of the edge to adj[i][j]".
That array is not just what gets reported - it is what Dijkstra runs on in step 1, so the tree itself changes, not only the weight column.
Reproduction 1: a second edge type corrupts the weights
Three vertices on a ROAD path of weight 1 per hop, plus one NOISE edge of weight 999 hanging off X. relTypes='ROAD' should exclude NOISE entirely.
MATCH (a:N {name:'X'}), (z:N {name:'Z'})
CALL algo.steinerTree([a,z],'ROAD','w') YIELD source, target, weight, totalWeight
{s: X, t: Y, weight: 999.0, totalWeight: 1000.0} <-- expected 1.0 and 2.0
{s: Y, t: Z, weight: 1.0, totalWeight: 1000.0}
The excluded edge's weight is attached to an included edge. totalWeight is 500x the true cost of the tree.
Reproduction 2: the analytical view changes the answer
Single edge type, no filter subtlety at all - X has two ROAD neighbours (Y at 1.0, Z at 50.0) and Y-Z costs 1.0. The cheapest tree connecting X and Z is X-Y-Z at 2.0.
OLTP {s: X, t: Y, weight: 1.0, totalWeight: 2.0} <-- correct
OLTP {s: Y, t: Z, weight: 1.0, totalWeight: 2.0}
CSR {s: X, t: Y, weight: 1.0, totalWeight: 51.0} <-- same query, view present
CSR {s: Z, t: Y, weight: 50.0, totalWeight: 51.0}
The CSR adjacency order need not match the OLTP getEdges order, so the positional pairing lands differently and the procedure picks a 51.0 tree over the 2.0 one. A Graph Analytical View is meant to be a transparent accelerator; here its mere presence changes a query's result, which is the worst failure mode a view can have and is not something a user has any way to anticipate.
Suggested fix, and a better one
The mechanical fix is to build the weight array by neighbour index rather than by position - getEdges(BOTH, relTypes) with the filter applied, resolve nbIdx = graph.indexOf(nbRid), find that neighbour's slot in adj[i] and write there. That closes both reproductions.
The better fix is to stop deriving weights from a second, independent traversal at all. GraphData already exposes edgeWeights(dir, weightProperty, relTypes), which returns weights aligned with adjacency(...) by construction and reads them from columnar storage on the CSR path instead of deserialising edge records. algo.apsp already uses exactly that. Switching algo.steinerTree to it removes the alignment question rather than answering it, is faster on the view path, and deletes buildWeightedAdj outright. edgeWeights returns null when the view has no edge properties, so the positional walk survives only as the OLTP fallback - and that fallback should be index-based too.
Worth checking in the same pass: whether any other procedure derives weights from an independent getEdges walk instead of edgeWeights(...), since the bug is a pattern rather than a line.
Secondary, same method
buildWeightedAdj calls graph.getVertex(i).getEdges(...) without a null check. GraphData.getVertex returns null by design for a vertex deleted since the CSR was built (it catches RecordNotFoundException and returns null), so a concurrent delete turns this into an NPE rather than a skipped vertex. Every other caller in the package null-checks it.
Related
#6289, PR #6298, #6263, #6300.
Found while working on #6289 (PR #6298) and verified with runnable reproductions before filing. Not a performance question like #6289 was -
algo.steinerTreereturns wrong trees and wrong weights, and it returns different ones depending on whether a Graph Analytical View happens to be present.Root cause
AlgoSteinerTree.buildWeightedAdjpairs weights with neighbours by position:adj[i]is the neighbour list after therelTypesfilter and in whatever order the backing store produced.tmpWis filled from an unfilteredgetEdges(BOTH)walk in OLTP order. Nothing reconciles the two -nbIdxis computed and then only used as a skip test, never as the index to write at. SoadjW[i][j]is the weight of "the j-th edge I happened to iterate", not "the weight of the edge toadj[i][j]".That array is not just what gets reported - it is what Dijkstra runs on in step 1, so the tree itself changes, not only the
weightcolumn.Reproduction 1: a second edge type corrupts the weights
Three vertices on a
ROADpath of weight 1 per hop, plus oneNOISEedge of weight 999 hanging off X.relTypes='ROAD'should excludeNOISEentirely.The excluded edge's weight is attached to an included edge.
totalWeightis 500x the true cost of the tree.Reproduction 2: the analytical view changes the answer
Single edge type, no filter subtlety at all - X has two
ROADneighbours (Y at 1.0, Z at 50.0) and Y-Z costs 1.0. The cheapest tree connecting X and Z is X-Y-Z at 2.0.The CSR adjacency order need not match the OLTP
getEdgesorder, so the positional pairing lands differently and the procedure picks a 51.0 tree over the 2.0 one. A Graph Analytical View is meant to be a transparent accelerator; here its mere presence changes a query's result, which is the worst failure mode a view can have and is not something a user has any way to anticipate.Suggested fix, and a better one
The mechanical fix is to build the weight array by neighbour index rather than by position -
getEdges(BOTH, relTypes)with the filter applied, resolvenbIdx = graph.indexOf(nbRid), find that neighbour's slot inadj[i]and write there. That closes both reproductions.The better fix is to stop deriving weights from a second, independent traversal at all.
GraphDataalready exposesedgeWeights(dir, weightProperty, relTypes), which returns weights aligned withadjacency(...)by construction and reads them from columnar storage on the CSR path instead of deserialising edge records.algo.apspalready uses exactly that. Switchingalgo.steinerTreeto it removes the alignment question rather than answering it, is faster on the view path, and deletesbuildWeightedAdjoutright.edgeWeightsreturns null when the view has no edge properties, so the positional walk survives only as the OLTP fallback - and that fallback should be index-based too.Worth checking in the same pass: whether any other procedure derives weights from an independent
getEdgeswalk instead ofedgeWeights(...), since the bug is a pattern rather than a line.Secondary, same method
buildWeightedAdjcallsgraph.getVertex(i).getEdges(...)without a null check.GraphData.getVertexreturns null by design for a vertex deleted since the CSR was built (it catchesRecordNotFoundExceptionand returns null), so a concurrent delete turns this into an NPE rather than a skipped vertex. Every other caller in the package null-checks it.Related
#6289, PR #6298, #6263, #6300.