Adding algorithms to GraphQL - #2696
Conversation
…r for NodeState (we use OutputTypedNodeState because there are no generic type parameters), similar to how PyOutputNodeState does it. Currently added pagerank
… contain all the values for that column. Each column is an output of the algorithm. We can then access the column's name and each output value. These values are row-aligned with the nodes of the node state. These nodes are queried and returned separately.
…tations. Added a comparison function to use for these. Added sum and mean functions in GraphQL. They can't use the Rust implementations because of trait bounds on the functions, so we must re-implement them to handle number casting (and possible errors there).
…by node) instead of by column. There is also a variant to return rows without column names
…lgorithms in GraphQL
…t_batch algorithms in GraphQL
…bounded, temporal_three_node_motif, and temporally_reachable_nodes algorithms in GraphQL
… input as well as an optional node filter. We want to match python by making this a general filter, which doesn't currently exist in GraphQL.
…s (NodeFilter, EdgeFilter, GraphFilter). This allows a combination of all 3 filters to be passed as a single filter. Use them in in_component and out_component.
…icient, global_reciprocity algorithms in GraphQL
…egree, min_out_degree, triangle_count, and triplet_count algorithms in GraphQL
…ti, Matching, and max_weight_matching algorithms in GraphQL
…lub_coefficient algorithm by taking rolling window args in GraphQL.
There was a problem hiding this comment.
⚠️ Performance Alert ⚠️
Possible performance regression was detected for benchmark 'Rust Benchmark'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 2.
| Benchmark suite | Current: 6aa20a0 | Previous: 9823ef7 | Ratio |
|---|---|---|---|
lotr_graph/num_edges |
4 ns/iter (± 0) |
0 ns/iter (± 0) |
+∞ |
lotr_graph/num_nodes |
6 ns/iter (± 0) |
1 ns/iter (± 0) |
6 |
lotr_graph/graph_latest |
3 ns/iter (± 0) |
0 ns/iter (± 0) |
+∞ |
lotr_graph_materialise/materialize |
7909266 ns/iter (± 82084) |
1564816 ns/iter (± 35303) |
5.05 |
lotr_graph_window_100/num_nodes |
15 ns/iter (± 0) |
5 ns/iter (± 0) |
3 |
lotr_graph_window_100_materialise/materialize |
7908831 ns/iter (± 35091) |
1669150 ns/iter (± 10700) |
4.74 |
lotr_graph_window_10/has_node_existing |
136 ns/iter (± 11) |
62 ns/iter (± 11) |
2.19 |
lotr_graph_window_10_materialise/materialize |
3317912 ns/iter (± 14022) |
971980 ns/iter (± 4278) |
3.41 |
lotr_graph_subgraph_10pc_materialise/materialize |
2059252 ns/iter (± 12281) |
334634 ns/iter (± 1287) |
6.15 |
lotr_graph_subgraph_10pc_windowed/has_node_existing |
148 ns/iter (± 10) |
62 ns/iter (± 14) |
2.39 |
lotr_graph_subgraph_10pc_windowed_materialise/materialize |
1264716 ns/iter (± 20374) |
230399 ns/iter (± 2617) |
5.49 |
lotr_graph_window_50_layered/num_edges_temporal |
146584 ns/iter (± 2063) |
70121 ns/iter (± 7586) |
2.09 |
lotr_graph_window_50_layered/has_node_existing |
377 ns/iter (± 22) |
129 ns/iter (± 12) |
2.92 |
lotr_graph_window_50_layered/graph_latest |
88810 ns/iter (± 1281) |
36649 ns/iter (± 916) |
2.42 |
lotr_graph_window_50_layered_materialise/materialize |
28985918 ns/iter (± 177795) |
3488825 ns/iter (± 24948) |
8.31 |
lotr_graph_persistent_window_50_layered/num_edges_temporal |
586545 ns/iter (± 5990) |
192686 ns/iter (± 1569) |
3.04 |
lotr_graph_persistent_window_50_layered/has_node_existing |
425 ns/iter (± 375) |
174 ns/iter (± 83) |
2.44 |
lotr_graph_persistent_window_50_layered/has_node_nonexisting |
5 ns/iter (± 0) |
2 ns/iter (± 0) |
2.50 |
lotr_graph_persistent_window_50_layered/graph_latest |
124762 ns/iter (± 2834) |
57549 ns/iter (± 4809) |
2.17 |
lotr_graph_persistent_window_50_layered_materialise/materialize |
51626948 ns/iter (± 591544) |
5298035 ns/iter (± 147912) |
9.74 |
This comment was automatically generated by workflow using github-action-benchmark.
miratepuffin
left a comment
There was a problem hiding this comment.
Probably a good idea to split the algorithms folder into a similar structure as the actual algorithms folder
| pub(crate) mod weakly_connected_components; | ||
|
|
||
| /// A graph algorithm executable through the GraphQL API. | ||
| pub(crate) trait GqlExecutableAlgorithm: 'static { |
There was a problem hiding this comment.
There is a lot packed into this mod - I would split it out into different files
There was a problem hiding this comment.
Ideally docs would be sync'd with python docs somehow
| Layers(GraphLayersExpr), | ||
| } | ||
|
|
||
| /// A composite filter producing a graph view, bundling the graph-view, node, |
There was a problem hiding this comment.
I have something similar to this in my PR, we will need to merge - I don't think you are support And/Or here?
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_algorithm_node_state_ops() { |
There was a problem hiding this comment.
I would put all of the tests into the actual algorithms themselves, gonna be easier to see which ones haven't been tested then.
Might not be a terrible idea to sanity check in python by running on the local graph and then via graphql and seeing if we get the same result?
# Conflicts: # raphtory-graphql/src/model/graph/mod.rs # raphtory-graphql/src/model/mod.rs # raphtory/src/errors.rs
…nto respective files
… mod.rs short and structural.
| Ok(alternating_mask(graph).into()) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
I don't think we need to expose this - its a test algorithm
| args: A::Args, | ||
| ) -> Result<A::Output, GraphError> { | ||
| let graph = self.graph.clone(); | ||
| blocking_compute(move || A::execute(&graph, args)).await |
There was a problem hiding this comment.
This is probably not an issue right now, but we should probably highly weight some of these algorithms in terms of graphql complexity?
ljeub-pometry
left a comment
There was a problem hiding this comment.
I think all the extra abstraction and code for each algorithm isn't actually needed?
| /// Name of the column. | ||
| name: String, | ||
| /// The values of this column; `values[i]` belongs to `NodeState.nodes[i]`. | ||
| values: Vec<GqlNodeStateValue>, |
There was a problem hiding this comment.
This isn't great, you need to reallocate a new vector to make this instead of reusing the underlying column!
There was a problem hiding this comment.
Also, SimpleObject is probably not the right thing to use here? Should be resolved with accessors for stuff like sum etc...
|
|
||
| impl GqlNodeState { | ||
| /// A slice of `limit` rows starting at `start`, as a node state of its own. | ||
| fn slice(&self, start: usize, limit: usize) -> GqlNodeState { |
There was a problem hiding this comment.
we should expose an api like this on the NodeState itself
| } | ||
|
|
||
| /// Returns the values for a node, one entry per column; null if the node has no value in this NodeState. | ||
| async fn get( |
There was a problem hiding this comment.
shouldn't this return a Option<GqlNodeStateRow>?
| async fn min( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| ) -> Option<GqlNodeStateItem> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| self_clone.check_comparable(&column)?; | ||
| let item = self_clone | ||
| .node_state | ||
| .min_item_by(column_cmp(&column, true))?; | ||
| self_clone.item_from_row(&column, item) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Maximum `(node, value)` of a column. Null if the column does not exist, is empty, | ||
| /// or its values are not comparable (e.g. contains nodes). | ||
| async fn max( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| ) -> Option<GqlNodeStateItem> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| self_clone.check_comparable(&column)?; | ||
| let item = self_clone | ||
| .node_state | ||
| .max_item_by(column_cmp(&column, false))?; | ||
| self_clone.item_from_row(&column, item) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Sum of a column's values, skipping empty cells. Null if the column does not exist, is empty, | ||
| /// or is not additive (e.g. contains nodes). | ||
| async fn sum( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| ) -> Option<GqlPropertyOutputVal> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| let mut values = self_clone.column_value_iter(&column)?; | ||
| let mut acc = values.next()?; | ||
| if !acc.dtype().has_add() { | ||
| return None; | ||
| } | ||
| for value in values { | ||
| acc = acc.add(value)?; | ||
| } | ||
| Some(GqlPropertyOutputVal(acc)) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Mean of a column's values as a float, skipping empty cells. Null if the column does not exist, | ||
| /// is empty, or has any non-numeric value. | ||
| async fn mean( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| ) -> Option<GqlPropertyOutputVal> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| let mut values = self_clone.column_value_iter(&column)?; | ||
| let mut sum = values.next()?.as_f64()?; | ||
| let mut count = 1usize; | ||
| for value in values { | ||
| sum += value.as_f64()?; | ||
| count += 1; | ||
| } | ||
| Some(GqlPropertyOutputVal(Prop::F64(sum / count as f64))) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Median `(node, value)` of a column (upper median on even lengths). Null if the column | ||
| /// does not exist, is empty, or is not comparable (e.g. contains nodes). | ||
| async fn median( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| ) -> Option<GqlNodeStateItem> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| self_clone.check_comparable(&column)?; | ||
| let item = self_clone | ||
| .node_state | ||
| .median_item_by(column_cmp(&column, true))?; | ||
| self_clone.item_from_row(&column, item) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Returns the `k` rows with the largest values in a column. Empty cells rank | ||
| /// lowest, so they are only included if fewer than `k` rows have a value. | ||
| /// Null if the column does not exist, is empty, or is not comparable. | ||
| async fn top_k( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| #[graphql(desc = "Number of rows to return.")] k: usize, | ||
| ) -> Option<GqlNodeState> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| self_clone.check_comparable(&column)?; | ||
| Some(GqlNodeState { | ||
| node_state: self_clone | ||
| .node_state | ||
| .top_k_by(column_cmp(&column, false), k), | ||
| }) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Returns the `k` rows with the smallest values in a column. Empty cells rank | ||
| /// highest, so they are only included if fewer than `k` rows have a value. | ||
| /// Null if the column does not exist, is empty, or is not comparable. | ||
| async fn bottom_k( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| #[graphql(desc = "Number of rows to return.")] k: usize, | ||
| ) -> Option<GqlNodeState> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| self_clone.check_comparable(&column)?; | ||
| Some(GqlNodeState { | ||
| node_state: self_clone | ||
| .node_state | ||
| .bottom_k_by(column_cmp(&column, true), k), | ||
| }) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Returns a view of this node state with the rows sorted by a column's values, | ||
| /// ascending with empty cells last. `reverse` flips the whole ordering, putting | ||
| /// empty cells first. Null if the column does not exist, is empty, or is not | ||
| /// comparable. | ||
| async fn sort_by_values( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| #[graphql(desc = "Sort in descending order instead. Defaults to false.")] reverse: Option< | ||
| bool, | ||
| >, | ||
| ) -> Option<GqlNodeState> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| self_clone.check_comparable(&column)?; | ||
| let cmp = column_cmp(&column, true); | ||
| let node_state = if reverse.unwrap_or(false) { | ||
| self_clone | ||
| .node_state | ||
| .sort_by_values_by(|a, b| cmp(a, b).reverse()) | ||
| } else { | ||
| self_clone.node_state.sort_by_values_by(cmp) | ||
| }; | ||
| Some(GqlNodeState { node_state }) | ||
| }) | ||
| .await | ||
| } | ||
|
|
||
| /// Groups the nodes by their value in a column. Nodes with an empty cell form | ||
| /// their own group. Null if the column does not exist or contains nodes. | ||
| async fn group_by( | ||
| &self, | ||
| #[graphql(desc = "Column name.")] column: String, | ||
| ) -> Option<Vec<GqlNodeStateGroup>> { | ||
| let self_clone = self.clone(); | ||
| blocking_compute(move || { | ||
| if !self_clone.is_prop_column(&column) { | ||
| return None; | ||
| } | ||
| let groups = self_clone.node_state.group_by(|mut row| { | ||
| GroupKey::new(row.swap_remove(&column).flatten().map(|value| value.0)) | ||
| }); | ||
| Some( | ||
| groups | ||
| .into_iter_groups() | ||
| .map(|(key, nodes)| GqlNodeStateGroup { | ||
| value: key.value.map(GqlPropertyOutputVal), | ||
| nodes: GqlNodes::new(nodes), | ||
| }) | ||
| .collect(), | ||
| ) | ||
| }) | ||
| .await | ||
| } |
There was a problem hiding this comment.
We need proper single-column apis in the rust layer. However, the ordering based apis here should work with multiple columns, same as in rust.
|
|
||
| /// The columns of the node state, one per output field of the algorithm. | ||
| /// `values` are row-aligned with `nodes`. | ||
| async fn columns(&self) -> Vec<GqlNodeStateColumn> { |
There was a problem hiding this comment.
there should be column(name) as well to get a single column
| #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] | ||
| weight: Option<String>, | ||
| ) -> Result<GqlNodeState, GraphError> { | ||
| self.run::<GqlPagerank>(GqlPagerankArgs { |
There was a problem hiding this comment.
what is the point in the whole executable machinery when you are still writing all these functions here anyway? You could have e.g., just called blocking_compute(|| {pagerank()).await here?
| #[graphql(desc = "Edge property to use as weight. If unset, all edges have weight 1.")] | ||
| weight: Option<String>, | ||
| ) -> Result<GqlNodeState, GraphError> { | ||
| self.run::<GqlPagerank>(GqlPagerankArgs { |
There was a problem hiding this comment.
This whole executable machinery seems to be entirely useless? You could just change the run method to take a closure that has a DynamicGraph as input and a generic as output and have everything inline here without all the extra code for each algorithm!
What changes were proposed in this pull request?
Exposing the algorithms in GraphQL. They are currently only available in core Rust and Python.
Why are the changes needed?
Accessing them in GraphQL.
Does this PR introduce any user-facing change? If yes is this documented?
Yes, many GraphQL queries. All documented.
How was this patch tested?
Unit tests for each algorithm.
Are there any further changes required?
Yes, potential follow-ups on GqlNodeState ( #2694 ) and the algorithms ( #2695 )