diff --git a/app/models/concerns/async_devise_email.rb b/app/models/concerns/async_devise_email.rb index 38c99dc7e713dbaacc282a7d7cc43d4da23b0364..7cdbed2eef6261cdd7388e6fd6b9879de1627c68 100644 --- a/app/models/concerns/async_devise_email.rb +++ b/app/models/concerns/async_devise_email.rb @@ -2,6 +2,7 @@ module AsyncDeviseEmail extend ActiveSupport::Concern + include AfterCommitQueue private @@ -9,6 +10,8 @@ module AsyncDeviseEmail def send_devise_notification(notification, *args) return true unless can?(:receive_notifications) - devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend + run_after_commit_or_now do + devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend + end end end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 8618cdffbe459cce5bcd2cd4863d9a6fc5869887..112fcc389e3375a46c1f4fc37a6cc80a223d86c2 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -112,7 +112,10 @@ def post_update_hook end def after_accept_invite - notification_service.accept_group_invite(self) + run_after_commit_or_now do + notification_service.accept_group_invite(self) + end + update_two_factor_requirement super diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 995c26d7221c0fd24a32e6f57048c4107ef4f5d5..fe69d9f93825d3b89e3275608532e8c2eb4df39f 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -158,7 +158,9 @@ def post_destroy_hook end def after_accept_invite - notification_service.accept_project_invite(self) + run_after_commit_or_now do + notification_service.accept_project_invite(self) + end super end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 57eaf27069dd5927c95675b714877fa3cc858d9f..9f08ff9d66d07653f1bbaaa82b91cf5a32b81ab0 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -231,7 +231,10 @@ class << self # rubocop: disable CodeReuse/ServiceClass after_transition [:unchecked, :checking] => :cannot_be_merged do |merge_request, transition| if merge_request.notify_conflict? - NotificationService.new.merge_request_unmergeable(merge_request) + merge_request.run_after_commit do + NotificationService.new.merge_request_unmergeable(merge_request) + end + TodoService.new.merge_request_became_unmergeable(merge_request) end end diff --git a/app/models/user.rb b/app/models/user.rb index ba187ddfc239347854d199511a142eb0cc3b3156..fcf1f8bfc5829f2d70c4426fb4532e3cbdb744e6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -414,7 +414,9 @@ def blocked? after_transition any => :deactivated do |user| next unless Gitlab::CurrentSettings.user_deactivation_emails_enabled - NotificationService.new.user_deactivated(user.name, user.notification_email_or_default) + user.run_after_commit do + NotificationService.new.user_deactivated(user.name, user.notification_email_or_default) + end end # rubocop: enable CodeReuse/ServiceClass diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index 72492b6f5a5cd6a072a3af9c98d03bc5fca0e56b..d8d3542259016507c260c7b1f9b5788056c252f5 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -143,7 +143,12 @@ def notify_error project_id: project.id ) - notification_service.project_not_exported(project, current_user, shared.errors) + user = current_user + errors = shared.errors + + project.run_after_commit_or_now do |project| + NotificationService.new.project_not_exported(project, user, errors) + end end end end diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb index 2ea6c9a7343b80d1c3c2a0767e50176da29e0e48..c96524b824d60ed47fa43bc4c86cffe9e69b7214 100644 --- a/config/initializers/forbid_sidekiq_in_transactions.rb +++ b/config/initializers/forbid_sidekiq_in_transactions.rb @@ -16,19 +16,31 @@ def self.skip_transaction_check Thread.current[:sidekiq_worker_skip_transaction_check] end + def self.inside_transaction? + ::ApplicationRecord.inside_transaction? || ::Ci::ApplicationRecord.inside_transaction? + end + + def self.raise_exception_for_being_inside_a_transaction? + !skip_transaction_check && inside_transaction? + end + + def self.raise_inside_transaction_exception(cause:) + raise Sidekiq::Worker::EnqueueFromTransactionError, <<~MSG + #{cause} cannot be enqueued inside a transaction as this can lead to + race conditions when the worker runs before the transaction is committed and + tries to access a model that has not been saved yet. + + Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead. + MSG + end + module ClassMethods module NoEnqueueingFromTransactions %i(perform_async perform_at perform_in).each do |name| define_method(name) do |*args| - if !Sidekiq::Worker.skip_transaction_check && inside_transaction? + if Sidekiq::Worker.raise_exception_for_being_inside_a_transaction? begin - raise Sidekiq::Worker::EnqueueFromTransactionError, <<~MSG - `#{self}.#{name}` cannot be called inside a transaction as this can lead to - race conditions when the worker runs before the transaction is committed and - tries to access a model that has not been saved yet. - - Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead. - MSG + Sidekiq::Worker.raise_inside_transaction_exception(cause: "#{self}.#{name}") rescue Sidekiq::Worker::EnqueueFromTransactionError => e Gitlab::AppLogger.error(e.message) if ::Rails.env.production? Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) @@ -38,14 +50,50 @@ module NoEnqueueingFromTransactions super(*args) end end + end + + prepend NoEnqueueingFromTransactions + end + end +end - private +# We deliver emails using the `deliver_later` method and it uses ActiveJob +# under the hood, which later processes the email via the defined ActiveJob adapter's `enqueue` method. +# For GitLab, the ActiveJob adapter is Sidekiq (in development and production environments). +# We need to set the following up to override the ActiveJob adapater +# so as to ensure that no mailer jobs are enqueued from within a transaction. +module ActiveJob + module QueueAdapters + module NoEnqueueingFromTransactions + %i(enqueue enqueue_at).each do |name| + define_method(name) do |*args| + if Sidekiq::Worker.raise_exception_for_being_inside_a_transaction? + begin + job = args.first + Sidekiq::Worker.raise_inside_transaction_exception( + cause: "The #{job.class} job, enqueued into the queue: #{job.queue_name}" + ) + rescue Sidekiq::Worker::EnqueueFromTransactionError => e + Gitlab::AppLogger.error(e.message) if ::Rails.env.production? + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) + end + end - def inside_transaction? - ::ApplicationRecord.inside_transaction? || ::Ci::ApplicationRecord.inside_transaction? + super(*args) end end + end + + # This adapter is used in development & production environments. + class SidekiqAdapter + prepend NoEnqueueingFromTransactions + end + # This adapter is used in test environment. + # If we don't override the test environment adapter, + # we won't be seeing any failing jobs during the CI run, + # even if we enqueue mailers from within a transaction. + class TestAdapter prepend NoEnqueueingFromTransactions end end diff --git a/ee/app/models/ee/project_import_state.rb b/ee/app/models/ee/project_import_state.rb index 2f649984cf698991d2b5bb8c7efc3f64390fd5c2..25b988105b3b5dd809bddbb2f387e7bb357e017c 100644 --- a/ee/app/models/ee/project_import_state.rb +++ b/ee/app/models/ee/project_import_state.rb @@ -32,7 +32,9 @@ module ProjectImportState after_transition started: :failed do |state, _| if state.mirror? && state.retry_limit_exceeded? - ::NotificationService.new.mirror_was_hard_failed(state.project) + state.run_after_commit do + ::NotificationService.new.mirror_was_hard_failed(state.project) + end end end diff --git a/spec/initializers/forbid_sidekiq_in_transactions_spec.rb b/spec/initializers/forbid_sidekiq_in_transactions_spec.rb index 6cd15d37ad4606439e24147219c3c64c7607089b..a89ac73f6fadce9350ba839ec2a5585f19951a6c 100644 --- a/spec/initializers/forbid_sidekiq_in_transactions_spec.rb +++ b/spec/initializers/forbid_sidekiq_in_transactions_spec.rb @@ -3,36 +3,57 @@ require 'spec_helper' RSpec.describe 'Sidekiq::Worker' do - let(:worker_class) do - Class.new do - include Sidekiq::Worker + shared_examples_for 'a forbiddable operation within a transaction' do + it 'allows the operation outside of a transaction' do + expect { operation }.not_to raise_error + end - def perform + it 'forbids the operation within a transaction' do + ApplicationRecord.transaction do + expect { operation }.to raise_error(Sidekiq::Worker::EnqueueFromTransactionError) end end - end - it 'allows sidekiq worker outside of a transaction' do - expect { worker_class.perform_async }.not_to raise_error - end + it 'allows the oepration within a transaction if skipped' do + Sidekiq::Worker.skipping_transaction_check do + ApplicationRecord.transaction do + expect { operation }.not_to raise_error + end + end + end - it 'forbids queue sidekiq worker in a transaction' do - Project.transaction do - expect { worker_class.perform_async }.to raise_error(Sidekiq::Worker::EnqueueFromTransactionError) + it 'forbids the operation if it is within a Ci::ApplicationRecord transaction' do + Ci::Pipeline.transaction do + expect { operation }.to raise_error(Sidekiq::Worker::EnqueueFromTransactionError) + end end end - it 'allows sidekiq worker in a transaction if skipped' do - Sidekiq::Worker.skipping_transaction_check do - Project.transaction do - expect { worker_class.perform_async }.not_to raise_error + context 'for sidekiq workers' do + let(:worker_class) do + Class.new do + include Sidekiq::Worker + + def perform + end end end + + let(:operation) { worker_class.perform_async } + + it_behaves_like 'a forbiddable operation within a transaction' end - it 'forbids queue sidekiq worker in a Ci::ApplicationRecord transaction' do - Ci::Pipeline.transaction do - expect { worker_class.perform_async }.to raise_error(Sidekiq::Worker::EnqueueFromTransactionError) + context 'for mailers' do + let(:mailer_class) do + Class.new(ApplicationMailer) do + def test_mail + end + end end + + let(:operation) { mailer_class.test_mail.deliver_later } + + it_behaves_like 'a forbiddable operation within a transaction' end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index b6ad66f41b50bdcbe13ba59e20e44e6d506eee19..a9796c28870b64f57ff9df206574e3252d1c0124 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -1858,7 +1858,7 @@ def invite_to_group(group, inviter:, user: nil, tasks_to_be_done: []) end end - subject { ActionMailer::Base.deliveries.last } + subject { ActionMailer::Base.deliveries.first } it_behaves_like 'an email sent from GitLab' it_behaves_like "a user cannot unsubscribe through footer link"