diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 961a7cb1ef69d9bc9fd7444c2691617d9d2223d2..e631bfc08e131324fcbe31cbb44d5180311cefa5 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -55,7 +55,7 @@ def error_check! error = if @merge_request.should_be_rebased? 'Only fast-forward merge is allowed for your project. Please update your source branch' - elsif !@merge_request.mergeable? + elsif !@merge_request.merged? && !@merge_request.mergeable? 'Merge request is not mergeable' elsif !@merge_request.squash && project.squash_always? 'This project requires squashing commits when merge requests are accepted.' diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index fdf8f442297efefa713c41c2204a6fcff6c1cc92..2d45f2aceb841c44636d9f3a0d4b17d709548146 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -8,18 +8,31 @@ module MergeRequests # class PostMergeService < MergeRequests::BaseService def execute(merge_request) - merge_request.mark_as_merged - close_issues(merge_request) - todo_service.merge_merge_request(merge_request, current_user) - create_event(merge_request) - create_note(merge_request) + return if merge_request.merged? + + # These operations need to happen transactionally + ActiveRecord::Base.transaction(requires_new: true) do + merge_request.mark_as_merged + + # These options do not call external services and should be + # quick enough to put in a transaction + create_event(merge_request) + todo_service.merge_merge_request(merge_request, current_user) + end + notification_service.merge_mr(merge_request, current_user) - execute_hooks(merge_request, 'merge') + create_note(merge_request) + close_issues(merge_request) invalidate_cache_counts(merge_request, users: merge_request.assignees) merge_request.update_project_counter_caches delete_non_latest_diffs(merge_request) cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) + + # Anything after this point will be executed at-most-once. Less important activity only + # TODO: make all the work in here a separate sidekiq job so it can go in the transaction + # Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/228803 + execute_hooks(merge_request, 'merge') end private diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index faa2e9215812610eea0a60ecc6751f1913e8aeae..94ce7098d3a445c386286c281918ec49670e71ae 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -7,9 +7,7 @@ class SquashService < MergeRequests::BaseService def execute # If performing a squash would result in no change, then # immediately return a success message without performing a squash - if merge_request.commits_count < 2 && message.nil? - return success(squash_sha: merge_request.diff_head_sha) - end + return success(squash_sha: merge_request.diff_head_sha) if squash_redundant? return error(s_('MergeRequests|This project does not allow squashing commits when merge requests are accepted.')) if squash_forbidden? @@ -25,6 +23,12 @@ def execute private + def squash_redundant? + return true if merge_request.merged? + + merge_request.commits_count < 2 && message.nil? + end + def squash! squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message) diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 5148772c8811748be8bbd47091fcf5287384b1a0..3d243cfd00c3bde65e0f3b6102c6661276653191 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1426,7 +1426,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: true :tags: [] - :name: merge_request_mergeability_check :feature_category: :source_code_management diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 270bd831f96938c3f944908c67004540e5c1eb35..fbebaa925b4ee8e7ffdc148abb00ec72d5773d0f 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -7,6 +7,7 @@ class MergeWorker # rubocop:disable Scalability/IdempotentWorker urgency :high weight 5 loggable_arguments 2 + idempotent! def perform(merge_request_id, current_user_id, params) params = params.with_indifferent_access diff --git a/changelogs/unreleased/32456-make-mergeservice-idempotent.yml b/changelogs/unreleased/32456-make-mergeservice-idempotent.yml new file mode 100644 index 0000000000000000000000000000000000000000..62186122c69932a239e2d97302742a67aaad64ae --- /dev/null +++ b/changelogs/unreleased/32456-make-mergeservice-idempotent.yml @@ -0,0 +1,5 @@ +--- +title: Make MergeService idempotent +merge_request: 32456 +author: +type: changed diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index 11e341994f76dbb02c981f46d5dca57b2b86145b..8f3d38bd334578e099cbc1ecd0c42790b82781aa 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -64,6 +64,23 @@ end end + it 'is idempotent' do + repository = project.repository + commit_count = repository.commit_count + merge_commit = merge_request.merge_commit.id + + # a first invocation of execute is performed on the before block + service.execute(merge_request) + + expect(merge_request.merge_error).to be_falsey + expect(merge_request).to be_valid + expect(merge_request).to be_merged + + expect(repository.commits_by(oids: [merge_commit]).size).to eq(1) + expect(repository.commit_count).to eq(commit_count) + expect(merge_request.in_progress_merge_commit_sha).to be_nil + end + context 'when squashing' do let(:merge_params) do { commit_message: 'Merge commit message', @@ -288,6 +305,27 @@ .and_call_original service.execute(merge_request) end + + it 'does not fail to be idempotent when there is a Gitaly error' do + # This arose from an issue where Gitaly failed at a certain point + # and MergeService kept running PostMergeService and creating + # additional notifications. This spec makes sure if a Gitaly error + # does happen, MergeService will just quietly keep trying until + # the branch is removed. + # https://gitlab.com/gitlab-org/gitlab/-/issues/213620#note_331782036 + + # This simulates a Gitaly error when trying to delete a branch + expect_any_instance_of(Gitlab::GitalyClient::OperationService) + .to receive(:user_delete_branch).exactly(4).times + .and_raise(GRPC::FailedPrecondition) + + # Only one notification should be sent out: + expect(NotificationRecipients::BuildService) + .to receive(:build_recipients) + .exactly(:once).and_call_original + + 4.times { expect { service.execute(merge_request) }.to raise_error(Gitlab::Git::CommandError) } + end end end end diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb index a51a896ca96481b7785cad9595cdc8a519e832f9..58e8c250d95fb5444b14cd99c5a726a2d1578bd3 100644 --- a/spec/services/merge_requests/post_merge_service_spec.rb +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -19,7 +19,6 @@ it 'refreshes the number of open merge requests for a valid MR', :use_clean_rails_memory_store_caching do # Cache the counter before the MR changed state. project.open_merge_requests_count - merge_request.update!(state: 'merged') expect { subject }.to change { project.open_merge_requests_count }.from(1).to(0) end diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb index 97e8aeb616e3c31940d795a65547192cecc56407..fc0508702251273a2eb0b332f7db8f510b71e943 100644 --- a/spec/workers/merge_worker_spec.rb +++ b/spec/workers/merge_worker_spec.rb @@ -29,5 +29,23 @@ source_project.repository.expire_branches_cache expect(source_project.repository.branch_names).not_to include('markdown') end + + it_behaves_like 'an idempotent worker' do + let(:job_args) do + [ + merge_request.id, + merge_request.author_id, + commit_message: 'wow such merge', + sha: merge_request.diff_head_sha + ] + end + + it 'the merge request is still shown as merged' do + subject + + merge_request.reload + expect(merge_request).to be_merged + end + end end end