-
Notifications
You must be signed in to change notification settings - Fork 48
perf(analysis): paginate before hydration to fix component search timeout #2446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -520,6 +520,176 @@ impl AnalysisService { | |
| .await | ||
| } | ||
|
|
||
| /// Count nodes matching the query across all graphs, without hydrating them. | ||
| fn count_matching_nodes(query: &GraphQuery, graphs: &[(Uuid, Arc<PackageGraph>)]) -> u64 { | ||
| graphs | ||
| .iter() | ||
| .map(|(_, graph)| { | ||
| graph | ||
| .node_indices() | ||
| .filter(|&i| Self::filter(graph, query, i)) | ||
| .count() as u64 | ||
| }) | ||
| .sum() | ||
|
Comment on lines
+524
to
+533
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (performance): Avoid double iteration when computing totals for paginated queries to reduce cost on large graphs When Suggested implementation: })
}To fully adopt the "single traversal" approach and remove the double walk:
These changes will ensure that totals for paginated queries are derived during the same pass that builds the page, avoiding the double iteration and associated cost on large graphs. |
||
| } | ||
|
|
||
| /// Collect nodes from the graph, applying pagination at the index level. | ||
| /// | ||
| /// Flattens matching nodes across ALL graphs into a single sequence, sorts | ||
| /// by `(sbom_id, node_id)` for deterministic ordering, then applies skip/take | ||
| /// globally. Only the paged subset runs through the expensive `create` closure. | ||
| #[instrument(skip(self, create, graphs))] | ||
| async fn collect_graph_paged<'a, 'g, F, Fut>( | ||
| &self, | ||
| query: impl Into<GraphQuery<'a>> + Debug, | ||
| graphs: &'g [(Uuid, Arc<PackageGraph>)], | ||
| concurrency: usize, | ||
| offset: usize, | ||
| limit: usize, | ||
| create: F, | ||
| ) -> Result<Vec<Node>, Error> | ||
| where | ||
| F: Fn(&'g Graph<graph::Node, Relationship>, NodeIndex, &'g graph::Node) -> Fut + Clone, | ||
| Fut: Future<Output = Result<Node, Error>>, | ||
| { | ||
| let query = query.into(); | ||
|
|
||
| let mut matching: Vec<_> = graphs | ||
| .iter() | ||
| .flat_map(|(_, graph)| { | ||
| graph | ||
| .node_indices() | ||
| .filter(|&i| Self::filter(graph, &query, i)) | ||
| .filter_map(|i| graph.node_weight(i).map(|w| (graph.as_ref(), i, w))) | ||
| }) | ||
| .collect(); | ||
|
|
||
| matching.sort_by(|a, b| { | ||
| a.2.sbom_id | ||
| .cmp(&b.2.sbom_id) | ||
| .then_with(|| a.2.node_id.cmp(&b.2.node_id)) | ||
| }); | ||
|
|
||
| let matching: Vec<_> = matching.into_iter().skip(offset).take(limit).collect(); | ||
|
|
||
| stream::iter(matching) | ||
| .map(|(graph, node_index, package_node)| { | ||
| let create = create.clone(); | ||
| create(graph, node_index, package_node) | ||
| }) | ||
| .buffer_unordered(concurrency) | ||
|
Comment on lines
+575
to
+580
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): Using
|
||
| .try_collect::<Vec<_>>() | ||
| .await | ||
| } | ||
|
|
||
| /// Run graph query with pagination applied before hydration. | ||
| #[instrument(skip(self, connection, graphs))] | ||
| async fn run_graph_query_paged<'a, C: ConnectionTrait>( | ||
| &self, | ||
| query: impl Into<GraphQuery<'a>> + Debug, | ||
| options: QueryOptions, | ||
| graphs: &[(Uuid, Arc<PackageGraph>)], | ||
| paginated: impl Pagination, | ||
| connection: &C, | ||
| ) -> Result<PaginatedResults<Node>, Error> { | ||
| let query = query.into(); | ||
| let relationships = options.relationships; | ||
| tracing::debug!(?relationships, "relations"); | ||
|
|
||
| let total = if paginated.total() { | ||
| Some(Self::count_matching_nodes(&query, graphs)) | ||
| } else { | ||
| None | ||
| }; | ||
|
|
||
| let limit = paginated.limit() as usize; | ||
| let offset = paginated.offset() as usize; | ||
|
|
||
| if limit == 0 { | ||
| return Ok(PaginatedResults { | ||
| items: vec![], | ||
| total, | ||
| }); | ||
| } | ||
|
|
||
| if total.is_some_and(|t| offset as u64 >= t) { | ||
| return Ok(PaginatedResults { | ||
| items: vec![], | ||
| total, | ||
| }); | ||
| } | ||
|
|
||
| let loader = &GraphLoader::new(self.clone()); | ||
|
|
||
| let items = self | ||
| .collect_graph_paged( | ||
| query, | ||
| graphs, | ||
| self.concurrency, | ||
| offset, | ||
| limit, | ||
| |graph, node_index, node| { | ||
| let graph_cache = self.inner.graph_cache.clone(); | ||
| let relationships = relationships.clone(); | ||
| async move { | ||
| tracing::trace!( | ||
| "Discovered node - sbom: {}, node: {}", | ||
| node.sbom_id, | ||
| node.node_id | ||
| ); | ||
|
|
||
| let ancestors = Collector::new( | ||
| &graph_cache, | ||
| graphs, | ||
| node.sbom_id, | ||
| graph, | ||
| node_index, | ||
| Direction::Incoming, | ||
| options.ancestors, | ||
| &relationships, | ||
| connection, | ||
| self.concurrency, | ||
| loader, | ||
| ) | ||
| .collect(); | ||
|
|
||
| let descendants = Collector::new( | ||
| &graph_cache, | ||
| graphs, | ||
| node.sbom_id, | ||
| graph, | ||
| node_index, | ||
| Direction::Outgoing, | ||
| options.descendants, | ||
| &relationships, | ||
| connection, | ||
| self.concurrency, | ||
| loader, | ||
| ) | ||
| .collect(); | ||
|
|
||
| let (ancestors, descendants) = futures::join!(ancestors, descendants); | ||
| let ancestors = ancestors?; | ||
| let descendants = descendants?; | ||
|
|
||
| let mut warnings = ancestors.1; | ||
| warnings.extend(descendants.1); | ||
|
|
||
| Ok(Node { | ||
| base: node.into(), | ||
| relationship: None, | ||
| ancestors: ancestors.0, | ||
| descendants: descendants.0, | ||
| warnings, | ||
| }) | ||
| } | ||
| }, | ||
| ) | ||
| .await?; | ||
|
|
||
| Ok(PaginatedResults { items, total }) | ||
| } | ||
|
|
||
| #[instrument(skip(self, connection, graphs))] | ||
| pub async fn run_graph_query<'a, C: ConnectionTrait>( | ||
| &self, | ||
|
|
@@ -620,11 +790,8 @@ impl AnalysisService { | |
| let options = options.into(); | ||
|
|
||
| let graphs = self.load_graphs(connection, distinct_sbom_ids).await?; | ||
| let components = self | ||
| .run_graph_query(query, options, &graphs, connection) | ||
| .await?; | ||
|
|
||
| Ok(paginated.paginate_array(&components)) | ||
| self.run_graph_query_paged(query, options, &graphs, paginated, connection) | ||
| .await | ||
| } | ||
|
|
||
| /// locate components, retrieve dependency information | ||
|
|
@@ -641,11 +808,8 @@ impl AnalysisService { | |
|
|
||
| let graphs = self.load_graphs_query(connection, query).await?; | ||
|
|
||
| let components = self | ||
| .run_graph_query(query, options, &graphs, connection) | ||
| .await?; | ||
|
|
||
| Ok(paginated.paginate_array(&components)) | ||
| self.run_graph_query_paged(query, options, &graphs, paginated, connection) | ||
| .await | ||
| } | ||
|
|
||
| pub(crate) async fn load_graphs_query( | ||
|
|
@@ -667,19 +831,15 @@ impl AnalysisService { | |
| let query = query.into(); | ||
| let options = options.into(); | ||
|
|
||
| // load only latest graphs | ||
| let graphs = self | ||
| .inner | ||
| .load_latest_graphs_query(connection, query) | ||
| .await?; | ||
|
|
||
| log::debug!("graph sbom count: {:?}", graphs.len()); | ||
|
|
||
| let components = self | ||
| .run_graph_query(query, options, &graphs, connection) | ||
| .await?; | ||
| tracing::debug!("graph sbom count: {:?}", graphs.len()); | ||
|
|
||
| Ok(paginated.paginate_array(&components)) | ||
| self.run_graph_query_paged(query, options, &graphs, paginated, connection) | ||
| .await | ||
| } | ||
|
|
||
| /// check if a node in the graph matches the provided query | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (performance): Ordering by
SbomIdat the database layer may have indexing implicationsThis adds deterministic ordering but may introduce an extra sort on the DB side if there’s no index on
SbomId(or if it doesn’t match the PK/index). Please check whether an appropriate index exists or whether this ordering can reuse an existing one. If not, and the number ofdistinct_sbom_idsis usually small, consider sorting client-side instead.Suggested implementation:
sbomtable exposes the primary key/column assbom_id. If the field name differs (e.g.sbom_idis nested or renamed), adjust the closure insort_by_keyaccordingly.distinct_sbom_idsis later used as an iterator or converted into another structure (e.g. mapping to(Uuid, Arc<PackageGraph>)), no other changes should be needed, since the vector is now deterministically ordered before subsequent processing.