Commit b80cecde authored by Alex Kalderimis's avatar Alex Kalderimis

Eliminate N+1 performance issues in MergeRequest.pipelines

This requires the use of the CachingArrayResolver due to the way
MergeRequest.pipelines is implemented.

This is OK to use since the cardinality of `MergeRequest.pipelines` is
low (collections with more than 20 elements are rare). We need to set
an appropriate max page size though, so we set that here to 500
pipelines for any one MR, which is very conservative, since in
gitlab-org/gitlab> the 99th percentile is 39 pipelines per MR.
parent 27b71f08
...@@ -5,14 +5,32 @@ module Resolvers ...@@ -5,14 +5,32 @@ module Resolvers
class MergeRequestPipelinesResolver < BaseResolver class MergeRequestPipelinesResolver < BaseResolver
# The GraphQL type here gets defined in this include # The GraphQL type here gets defined in this include
include ::ResolvesPipelines include ::ResolvesPipelines
include ::CachingArrayResolver
alias_method :merge_request, :object alias_method :merge_request, :object
# Return at most 500 pipelines for each MR.
# Merge requests generally have many fewer pipelines than this.
def self.field_options
super.merge(max_page_size: 500)
end
def resolve(**args) def resolve(**args)
return unless project return unless project
resolve_pipelines(project, args) super
.merge(merge_request.all_pipelines) end
def query_for(args)
resolve_pipelines(project, args).merge(merge_request.all_pipelines)
end
def model_class
::Ci::Pipeline
end
def query_input(**args)
args
end end
def project def project
......
...@@ -115,7 +115,7 @@ module Types ...@@ -115,7 +115,7 @@ module Types
description: 'The pipeline running on the branch HEAD of the merge request' description: 'The pipeline running on the branch HEAD of the merge request'
field :pipelines, field :pipelines,
null: true, null: true,
description: 'Pipelines for the merge request', description: 'Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.',
resolver: Resolvers::MergeRequestPipelinesResolver resolver: Resolvers::MergeRequestPipelinesResolver
field :milestone, Types::MilestoneType, null: true, field :milestone, Types::MilestoneType, null: true,
......
---
title: Eliminate N+1 performance issues in MergeRequest.pipelines in GraphQL API
merge_request: 47784
author:
type: fixed
...@@ -13188,7 +13188,8 @@ type MergeRequest implements CurrentUserTodos & Noteable { ...@@ -13188,7 +13188,8 @@ type MergeRequest implements CurrentUserTodos & Noteable {
): UserConnection ): UserConnection
""" """
Pipelines for the merge request Pipelines for the merge request. Note: for performance reasons, no more than
the most recent 500 pipelines will be returned.
""" """
pipelines( pipelines(
""" """
......
...@@ -36392,7 +36392,7 @@ ...@@ -36392,7 +36392,7 @@
}, },
{ {
"name": "pipelines", "name": "pipelines",
"description": "Pipelines for the merge request", "description": "Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.",
"args": [ "args": [
{ {
"name": "status", "name": "status",
...@@ -2043,7 +2043,7 @@ Autogenerated return type of MarkAsSpamSnippet. ...@@ -2043,7 +2043,7 @@ Autogenerated return type of MarkAsSpamSnippet.
| `milestone` | Milestone | The milestone of the merge request | | `milestone` | Milestone | The milestone of the merge request |
| `notes` | NoteConnection! | All notes on this noteable | | `notes` | NoteConnection! | All notes on this noteable |
| `participants` | UserConnection | Participants in the merge request | | `participants` | UserConnection | Participants in the merge request |
| `pipelines` | PipelineConnection | Pipelines for the merge request | | `pipelines` | PipelineConnection | Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned. |
| `project` | Project! | Alias for target_project | | `project` | Project! | Alias for target_project |
| `projectId` | Int! | ID of the merge request project | | `projectId` | Int! | ID of the merge request project |
| `rebaseCommitSha` | String | Rebase commit SHA of the merge request | | `rebaseCommitSha` | String | Rebase commit SHA of the merge request |
......
...@@ -24,7 +24,7 @@ RSpec.describe Resolvers::MergeRequestPipelinesResolver do ...@@ -24,7 +24,7 @@ RSpec.describe Resolvers::MergeRequestPipelinesResolver do
end end
def resolve_pipelines def resolve_pipelines
resolve(described_class, obj: merge_request, ctx: { current_user: current_user }) sync(resolve(described_class, obj: merge_request, ctx: { current_user: current_user }))
end end
it 'resolves only MRs for the passed merge request' do it 'resolves only MRs for the passed merge request' do
......
...@@ -39,6 +39,12 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do ...@@ -39,6 +39,12 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
end end
describe '#pipelines' do
subject { described_class.fields['pipelines'] }
it { is_expected.to have_attributes(max_page_size: 500) }
end
describe '#diff_stats_summary' do describe '#diff_stats_summary' do
subject { GitlabSchema.execute(query, context: { current_user: current_user }).as_json } subject { GitlabSchema.execute(query, context: { current_user: current_user }).as_json }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Query.project.mergeRequests.pipelines' do
include GraphqlHelpers
let_it_be(:project) { create(:project, :public, :repository) }
let_it_be(:author) { create(:user) }
let_it_be(:merge_requests) do
%i[with_diffs with_image_diffs conflict].map do |trait|
create(:merge_request, trait, author: author, source_project: project)
end
end
describe '.count' do
let(:query) do
<<~GQL
query($path: ID!, $first: Int) {
project(fullPath: $path) {
mergeRequests(first: $first) {
nodes {
iid
pipelines {
count
}
}
}
}
}
GQL
end
def run_query(first = nil)
post_graphql(query, current_user: author, variables: { path: project.full_path, first: first })
end
before do
merge_requests.each do |mr|
shas = mr.all_commits.limit(2).pluck(:sha)
shas.each do |sha|
create(:ci_pipeline, :success, project: project, ref: mr.source_branch, sha: sha)
end
end
end
it 'produces correct results' do
run_query(2)
p_nodes = graphql_data_at(:project, :merge_requests, :nodes)
expect(p_nodes).to all(match('iid' => be_present, 'pipelines' => match('count' => 2)))
end
it 'is scalable', :request_store, :use_clean_rails_memory_store_caching do
# warm up
run_query
expect { run_query(2) }.to(issue_same_number_of_queries_as { run_query(1) }.ignoring_cached_queries)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment