Skip to content

Implement multi-source shortest path query support - #654

Open
patelchaitany wants to merge 4 commits into
turing-db:mainfrom
patelchaitany:feature/617-multi-source-shortest-path
Open

Implement multi-source shortest path query support#654
patelchaitany wants to merge 4 commits into
turing-db:mainfrom
patelchaitany:feature/617-multi-source-shortest-path

Conversation

@patelchaitany

@patelchaitany patelchaitany commented May 27, 2026

Copy link
Copy Markdown

Add multiSourceShortestPath processor that computes shortest paths from a set of source nodes to all reachable targets, returning one row per (source, target) pair with distance and path — extends the existing shortestPath which only returns a single best pair. Closes #617

@patelchaitany
patelchaitany requested a review from rjb32 as a code owner May 27, 2026 08:33
@patelchaitany

Copy link
Copy Markdown
Author

@rjb32 if you get time pls review this PR.

@rjb32

rjb32 commented May 31, 2026

Copy link
Copy Markdown
Contributor

@rjb32 if you get time pls review this PR.

Thank you very much @patelchaitany! I will review it on Monday

@patelchaitany

Copy link
Copy Markdown
Author

Hey @rjb32! Whenever you get a moment, I'd love to get your thoughts on this PR. Thanks!

@sulaimansuhas

sulaimansuhas commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Hey @patelchaitany - I have looked at the PR and had a few overall comments:

  • Can you think of anyways we can re-use explored paths between source nodes? This would help make the algorithm a lot more efficient I feel.
  • I think there is a lot of overlap between the ShortestPath processor code and the multi-path shortest path processor code. It would be nice to wrap up common logic and data structures into a single ShortestPathUtils.h/.cpp file!
  • I think we need a lot more comments in your code - especially around the shortest path algorithm.

Overall the code looks good and conforms to our standards well:)!

ColumnVector<NodeID>* targetOutputCol,
ColumnVector<EdgePropType>* distCol,
ColumnVector<Path>* pathCol) {
DijkstraHeap heap;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these structures are going to be recreated per runDijkstra call, you could save them as members of the processor class and clear them on entry in the function

class MultiSourceShortestPathProcessor final : public Processor {
public:
using EdgePropType = T::Primitive;
using DijkstraHeap = std::priority_queue<MultiSourceDijkstraNode<EdgePropType>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably better to extract all these type definitions and the runDijkstra mechanics into some utility class that would wrap up everything related to Dijkstra algorithm

Comment thread query/plan/PipelineGenerator.cpp Outdated
throw PlannerException("Unsupported Edge Weight Type");
}
};
ValueTypeDispatcher {edgeType._valueType}.execute(process);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coding style: we prefer to use the parenthesis for constructors instead of braces, such as ValueTypeDispatcher(edgeType._valueType)

@patelchaitany
patelchaitany requested a review from rjb32 June 6, 2026 18:06
@patelchaitany

Copy link
Copy Markdown
Author

@rjb32 @sulaimansuhas Feedback addressed! Please take another look and let me know if any further changes are needed. Happy to revise.

@sulaimansuhas

Copy link
Copy Markdown
Contributor

Hey @patelchaitany. I looked over your changes and it mostly looks good! I have two comments:

  • I think the Dijkstra Search Algorithm itself should be pulled out into a common function, as there is a lot of overlapping logic that would be painful to keep in sync between these two implementations.
  • I feel that there is scope to optimise this code - we should be able to cache already explored paths in previous runs with different source nodes. Please give me your thoughts on this (even why it wouldn't be feasible).

thank you for your good work!

@patelchaitany

patelchaitany commented Jun 11, 2026

Copy link
Copy Markdown
Author

I'm going to expand ShortestPathUtils into a DijkstraRunner<T> class that owns the heap and value map as members (reused across calls instead of reallocated) and holds the core Dijkstra loop - expansion, stale filtering, relaxation, and path reconstruction. A stopAtFirst flag on run() handles both use cases: ShortestPathProcessor stops at the first settled target, MultiSourceShortestPathProcessor keeps going until all are found, so both processors shrink down to just port wiring and result extraction. On caching across source nodes - the shortest-path trees are source-dependent so intermediate results don't transfer cleanly; the early-exit we already have cuts out the main waste, and anything beyond that (bidirectional Dijkstra, landmarks) feels like a separate effort. Will also fix the ValueTypeDispatcher brace style Remy flagged.

@sulaimansuhas

sulaimansuhas commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Hey @patelchaitany - your code changes sound good, I'll be waiting to review! As for your comments on the shortest path optimisations:

If we take the shortest path between node a and z to be a->p->x->z, then for that path we are guaranteed that every subpath of the path is the optimal shortest path from the source node of that subpath to the target. So from this single traversal we can infer that the shortest path from p to z is p->x->z.

Given this finding, two optimisations I can think of off the top of my head:

  • if p was in the source node set we already have a shortest path to a node in the target set (z).
  • if in any other traversal we pop p from the top of the stack we immediately can know that there is a path to a target node and the size of the path.

these are the ideas that gave me the intuition for optimisation in this case. I think it'd be a shame to get this PR through without exploring these kind of optimisations as I think they have the potential to reduce a lot of computation.

I'd like to hear your thoughts on this, and thank you for all your good work!

Add multiSourceShortestPath query support that computes shortest paths
from a set of source nodes to all reachable targets, returning one row
per (source, target) pair with distance and path.
@patelchaitany
patelchaitany force-pushed the feature/617-multi-source-shortest-path branch from d6adf47 to dc37525 Compare June 13, 2026 19:08
@patelchaitany

patelchaitany commented Jun 16, 2026

Copy link
Copy Markdown
Author

@sulaimansuhas - subpath caching is in. After each run we cache intermediate-to-target subpaths, then before the next source we check the cache and skip Dijkstra if we already have the answer. Mid-traversal hits are handled as pending results that only finalize when the heap confirms optimality.

Right now the cache is query-scoped - thinking a cross-query version keyed to commit hashes (with eviction like ShardCache does for vectors) would be a natural follow-up. Thoughts?

@patelchaitany

Copy link
Copy Markdown
Author

Hey @rjb32 , @sulaimansuhas Is there any changes are required.

@sulaimansuhas

Copy link
Copy Markdown
Contributor

Hey @patelchaitany, sorry for taking so long! I looked through the PR it looks pretty good. I'll have some final comments that I'll add later today.

@sulaimansuhas sulaimansuhas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @patelchaitany, the overall logic of your change is sound. I have left quite a few comments explaining what would be needed to make this mergeable. I think we need a lot more comments around the algorithm - in places my previous comments have been removed where my code was copied. If you are using AI coding tools - I request that you write the comments yourself because we generally find the AI generated comments to not be that clear to human readers, and it will also help you reason about the logic itself.

void DijkstraRunner<T>::run(const DijkstraHeap<EdgePropType>& initialHeap,
const DijkstraValueMap<EdgePropType>& initialValues,
const std::unordered_set<NodeID>& targetNodes,
bool stopAtFirst,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like having the bool flag, I think it should be a number. This would make it trivial to create a top K shortest paths algorithm in the future.

const DijkstraNode<EdgePropType> val = _heap.top();
_heap.pop();

const auto it = _heapValueMap.find(val.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment explaining that we are removing the stale values here.


// Finalize pending cache-hit results whose distance cannot be beaten
// by any future path (all remaining nodes have distance >= val.distance).
for (auto pendingIt = _pendingResults.begin(); pendingIt != _pendingResults.end(); ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Please add more comments explaining what is happening here. And expand on the why - these are complex algorithms that we want to as readable as possible.

}
}

// Consult the subpath cache: if this settled node has known shortest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not clear at all, please clearly explain the logic behind this cache search better. Something like:
If the current popped node is found in the cache then we know we have the shortest path from our source to the Target Node in the cache entry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also please clarify the explanation on why we need the pending result sets. It is useful but it took me a while to understand it just based on the code.

const auto cacheIt = cache->find(val.id);
if (cacheIt != cache->end()) {
for (const SubpathCacheEntry<EdgePropType>& entry : cacheIt->second) {
if (!targetNodes.contains(entry.targetNode)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both these if conditions should be one statement.

private:
DijkstraHeap<EdgePropType> _heap;
DijkstraValueMap<EdgePropType> _heapValueMap;
std::unordered_set<NodeID> _settledTargets;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes more sense to pass the constructed target nodes to the Utils class as a const, and have the settledTargets passed to the function itself. This way we don't have to create a copy of the targetNodes set every time. This would be especially useful if the target nodes set itself is quite large.

std::vector<DijkstraResult<EdgePropType>> _results;
std::unordered_map<NodeID, DijkstraResult<EdgePropType>> _pendingResults;

ColumnNodeIDs* _inputNodes {nullptr};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can forward declare all these pointer members.

}

template <SupportedType T>
void DijkstraRunner<T>::expandNode(const DijkstraNode<EdgePropType>& node) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add back all the comments that were removed here ( see original algo)

Comment on lines +167 to +181
// Start with the cached path suffix: [target, edge, ..., edge, settledNode]
outputPath = cacheEntry.pathSuffix;

// Append the predecessor chain from settledNode back to the source.
auto lastNode = settledNode.prevNode;
auto edge = settledNode.edge;
while (lastNode.isValid()) {
outputPath.push_back(edge.getValue());
outputPath.push_back(lastNode.getValue());

const auto& pathInfo = _heapValueMap[lastNode];
lastNode = pathInfo.prevNode;
edge = pathInfo.edge;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Start with the cached path suffix: [target, edge, ..., edge, settledNode]
outputPath = cacheEntry.pathSuffix;
// Append the predecessor chain from settledNode back to the source.
auto lastNode = settledNode.prevNode;
auto edge = settledNode.edge;
while (lastNode.isValid()) {
outputPath.push_back(edge.getValue());
outputPath.push_back(lastNode.getValue());
const auto& pathInfo = _heapValueMap[lastNode];
lastNode = pathInfo.prevNode;
edge = pathInfo.edge;
}
}
// Start with the cached path suffix without the starting node: [target, edge, ..., edge]
outputPath = cacheEntry.pathSuffix | ranges::views::drop_last(1);
reconstructPath(settledNode, outputPath);
}
}

struct SubpathCacheEntry {
NodeID targetNode;
T distance {0};
Path pathSuffix;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pathSuffix is not the correct name for this. Path would be fine.

@patelchaitany

Copy link
Copy Markdown
Author

@sulaimansuhas, Okay I will address your comments and not try to use AI for comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Multi-Source Shortest Path Algorithm

3 participants