Preload pipeline data for project pipelines
When displaying the pipelines of a project we now preload the following data: 1. Authors of the commits that belong to these pipelines 2. The number of warnings per pipeline, which is used by Ci::Pipeline#has_warnings? == Commit Authors Previously this data was queried for every Commit separately, leading to 20 SQL queries being executed in the worst case. With an average of 3 to 5 milliseconds per SQL query this could result in 100 milliseconds being spent in _just_ getting Commit authors. To preload this data Commit#author now uses BatchLoader (through Commit#lazy_author), and a separate module Gitlab::Ci::Pipeline::Preloader is used to ensure all authors are loaded before they are used. == Number of warnings This changes Ci::Pipeline#has_warnings? so it supports preloading of the number of warnings per pipeline. This removes the need for executing a COUNT(*) query for every pipeline just to see if it has any warnings or not.
This commit is contained in:
		
							parent
							
								
									70985aa19b
								
							
						
					
					
						commit
						19428e8008
					
				|  | @ -23,7 +23,7 @@ class Projects::PipelinesController < Projects::ApplicationController | ||||||
|     @finished_count = limited_pipelines_count(project, 'finished') |     @finished_count = limited_pipelines_count(project, 'finished') | ||||||
|     @pipelines_count = limited_pipelines_count(project) |     @pipelines_count = limited_pipelines_count(project) | ||||||
| 
 | 
 | ||||||
|     @pipelines.map(&:commit) # List commits for batch loading |     Gitlab::Ci::Pipeline::Preloader.preload(@pipelines) | ||||||
| 
 | 
 | ||||||
|     respond_to do |format| |     respond_to do |format| | ||||||
|       format.html |       format.html | ||||||
|  |  | ||||||
|  | @ -406,7 +406,18 @@ module Ci | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def has_warnings? |     def has_warnings? | ||||||
|       builds.latest.failed_but_allowed.any? |       number_of_warnings.positive? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def number_of_warnings | ||||||
|  |       BatchLoader.for(id).batch(default_value: 0) do |pipeline_ids, loader| | ||||||
|  |         Build.where(commit_id: pipeline_ids) | ||||||
|  |           .latest | ||||||
|  |           .failed_but_allowed | ||||||
|  |           .group(:commit_id) | ||||||
|  |           .count | ||||||
|  |           .each { |id, amount| loader.call(id, amount) } | ||||||
|  |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def set_config_source |     def set_config_source | ||||||
|  |  | ||||||
|  | @ -224,8 +224,34 @@ class Commit | ||||||
|     Gitlab::ClosingIssueExtractor.new(project, current_user).closed_by_message(safe_message) |     Gitlab::ClosingIssueExtractor.new(project, current_user).closed_by_message(safe_message) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def lazy_author | ||||||
|  |     BatchLoader.for(author_email.downcase).batch do |emails, loader| | ||||||
|  |       # A Hash that maps user Emails to the corresponding User objects. The | ||||||
|  |       # Emails at this point are the _primary_ Emails of the Users. | ||||||
|  |       users_for_emails = User | ||||||
|  |         .by_any_email(emails) | ||||||
|  |         .each_with_object({}) { |user, hash| hash[user.email] = user } | ||||||
|  | 
 | ||||||
|  |       users_for_ids = users_for_emails | ||||||
|  |         .values | ||||||
|  |         .each_with_object({}) { |user, hash| hash[user.id] = user } | ||||||
|  | 
 | ||||||
|  |       # Some commits may have used an alternative Email address. In this case we | ||||||
|  |       # need to query the "emails" table to map those addresses to User objects. | ||||||
|  |       Email | ||||||
|  |         .where(email: emails - users_for_emails.keys) | ||||||
|  |         .pluck(:email, :user_id) | ||||||
|  |         .each { |(email, id)| users_for_emails[email] = users_for_ids[id] } | ||||||
|  | 
 | ||||||
|  |       users_for_emails.each { |email, user| loader.call(email, user) } | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def author |   def author | ||||||
|     User.find_by_any_email(author_email.downcase) |     # We use __sync so that we get the actual objects back (including an actual | ||||||
|  |     # nil), instead of a wrapper, as returning a wrapped nil breaks a lot of | ||||||
|  |     # code. | ||||||
|  |     lazy_author.__sync | ||||||
|   end |   end | ||||||
|   request_cache(:author) { author_email.downcase } |   request_cache(:author) { author_email.downcase } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,28 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | module Gitlab | ||||||
|  |   module Ci | ||||||
|  |     module Pipeline | ||||||
|  |       # Class for preloading data associated with pipelines such as commit | ||||||
|  |       # authors. | ||||||
|  |       module Preloader | ||||||
|  |         def self.preload(pipelines) | ||||||
|  |           # This ensures that all the pipeline commits are eager loaded before we | ||||||
|  |           # start using them. | ||||||
|  |           pipelines.each(&:commit) | ||||||
|  | 
 | ||||||
|  |           pipelines.each do |pipeline| | ||||||
|  |             # This preloads the author of every commit. We're using "lazy_author" | ||||||
|  |             # here since "author" immediately loads the data on the first call. | ||||||
|  |             pipeline.commit.try(:lazy_author) | ||||||
|  | 
 | ||||||
|  |             # This preloads the number of warnings for every pipeline, ensuring | ||||||
|  |             # that Ci::Pipeline#has_warnings? doesn't execute any additional | ||||||
|  |             # queries. | ||||||
|  |             pipeline.number_of_warnings | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'spec_helper' | ||||||
|  | 
 | ||||||
|  | describe Gitlab::Ci::Pipeline::Preloader do | ||||||
|  |   describe '.preload' do | ||||||
|  |     it 'preloads the author of every pipeline commit' do | ||||||
|  |       commit = double(:commit) | ||||||
|  |       pipeline = double(:pipeline, commit: commit) | ||||||
|  | 
 | ||||||
|  |       expect(commit) | ||||||
|  |         .to receive(:lazy_author) | ||||||
|  | 
 | ||||||
|  |       expect(pipeline) | ||||||
|  |         .to receive(:number_of_warnings) | ||||||
|  | 
 | ||||||
|  |       described_class.preload([pipeline]) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -774,6 +774,33 @@ describe Ci::Pipeline, :mailer do | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   describe '#number_of_warnings' do | ||||||
|  |     it 'returns the number of warnings' do | ||||||
|  |       create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop') | ||||||
|  | 
 | ||||||
|  |       expect(pipeline.number_of_warnings).to eq(1) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'supports eager loading of the number of warnings' do | ||||||
|  |       pipeline2 = create(:ci_empty_pipeline, status: :created, project: project) | ||||||
|  | 
 | ||||||
|  |       create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline, name: 'rubocop') | ||||||
|  |       create(:ci_build, :allowed_to_fail, :failed, pipeline: pipeline2, name: 'rubocop') | ||||||
|  | 
 | ||||||
|  |       pipelines = project.pipelines.to_a | ||||||
|  | 
 | ||||||
|  |       pipelines.each(&:number_of_warnings) | ||||||
|  | 
 | ||||||
|  |       # To run the queries we need to actually use the lazy objects, which we do | ||||||
|  |       # by just sending "to_i" to them. | ||||||
|  |       amount = ActiveRecord::QueryRecorder | ||||||
|  |         .new { pipelines.each { |p| p.number_of_warnings.to_i } } | ||||||
|  |         .count | ||||||
|  | 
 | ||||||
|  |       expect(amount).to eq(1) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   shared_context 'with some outdated pipelines' do |   shared_context 'with some outdated pipelines' do | ||||||
|     before do |     before do | ||||||
|       create_pipeline(:canceled, 'ref', 'A', project) |       create_pipeline(:canceled, 'ref', 'A', project) | ||||||
|  |  | ||||||
|  | @ -52,22 +52,98 @@ describe Commit do | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '#author' do |   describe '#author', :request_store do | ||||||
|     it 'looks up the author in a case-insensitive way' do |     it 'looks up the author in a case-insensitive way' do | ||||||
|       user = create(:user, email: commit.author_email.upcase) |       user = create(:user, email: commit.author_email.upcase) | ||||||
|       expect(commit.author).to eq(user) |       expect(commit.author).to eq(user) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'caches the author', :request_store do |     it 'caches the author' do | ||||||
|       user = create(:user, email: commit.author_email) |       user = create(:user, email: commit.author_email) | ||||||
|       expect(User).to receive(:find_by_any_email).and_call_original |  | ||||||
| 
 | 
 | ||||||
|       expect(commit.author).to eq(user) |       expect(commit.author).to eq(user) | ||||||
|  | 
 | ||||||
|       key = "Commit:author:#{commit.author_email.downcase}" |       key = "Commit:author:#{commit.author_email.downcase}" | ||||||
|       expect(RequestStore.store[key]).to eq(user) |  | ||||||
| 
 | 
 | ||||||
|  |       expect(RequestStore.store[key]).to eq(user) | ||||||
|       expect(commit.author).to eq(user) |       expect(commit.author).to eq(user) | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     context 'using eager loading' do | ||||||
|  |       let!(:alice) { create(:user, email: 'alice@example.com') } | ||||||
|  |       let!(:bob) { create(:user, email: 'hunter2@example.com') } | ||||||
|  | 
 | ||||||
|  |       let(:alice_commit) do | ||||||
|  |         described_class.new(RepoHelpers.sample_commit, project).tap do |c| | ||||||
|  |           c.author_email = 'alice@example.com' | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       let(:bob_commit) do | ||||||
|  |         # The commit for Bob uses one of his alternative Emails, instead of the | ||||||
|  |         # primary one. | ||||||
|  |         described_class.new(RepoHelpers.sample_commit, project).tap do |c| | ||||||
|  |           c.author_email = 'bob@example.com' | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       let(:eve_commit) do | ||||||
|  |         described_class.new(RepoHelpers.sample_commit, project).tap do |c| | ||||||
|  |           c.author_email = 'eve@example.com' | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       let!(:commits) { [alice_commit, bob_commit, eve_commit] } | ||||||
|  | 
 | ||||||
|  |       before do | ||||||
|  |         create(:email, user: bob, email: 'bob@example.com') | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'executes only two SQL queries' do | ||||||
|  |         recorder = ActiveRecord::QueryRecorder.new do | ||||||
|  |           # Running this first ensures we don't run one query for every | ||||||
|  |           # commit. | ||||||
|  |           commits.each(&:lazy_author) | ||||||
|  | 
 | ||||||
|  |           # This forces the execution of the SQL queries necessary to load the | ||||||
|  |           # data. | ||||||
|  |           commits.each { |c| c.author.try(:id) } | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         expect(recorder.count).to eq(2) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it "preloads the authors for Commits matching a user's primary Email" do | ||||||
|  |         commits.each(&:lazy_author) | ||||||
|  | 
 | ||||||
|  |         expect(alice_commit.author).to eq(alice) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it "preloads the authors for Commits using a User's alternative Email" do | ||||||
|  |         commits.each(&:lazy_author) | ||||||
|  | 
 | ||||||
|  |         expect(bob_commit.author).to eq(bob) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'sets the author to Nil if an author could not be found for a Commit' do | ||||||
|  |         commits.each(&:lazy_author) | ||||||
|  | 
 | ||||||
|  |         expect(eve_commit.author).to be_nil | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'does not execute SQL queries once the authors are preloaded' do | ||||||
|  |         commits.each(&:lazy_author) | ||||||
|  |         commits.each { |c| c.author.try(:id) } | ||||||
|  | 
 | ||||||
|  |         recorder = ActiveRecord::QueryRecorder.new do | ||||||
|  |           alice_commit.author | ||||||
|  |           bob_commit.author | ||||||
|  |           eve_commit.author | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         expect(recorder.count).to be_zero | ||||||
|  |       end | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '#to_reference' do |   describe '#to_reference' do | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue