Commit bf6b7bbd authored by Corinna Wiesner's avatar Corinna Wiesner

Introduce concern to update highest role

Avoid calling a service from the model layer by removing the service and
introducing a concern with its logic.
parent 513d8a6b
# frozen_string_literal: true
module UpdateHighestRole
extend ActiveSupport::Concern
HIGHEST_ROLE_LEASE_TIMEOUT = 10.minutes.to_i
HIGHEST_ROLE_JOB_DELAY = 10.minutes
included do
after_commit :update_highest_role
end
private
# Schedule a Sidekiq job to update the highest role for a User
#
# The job will be called outside of a transaction in order to ensure the changes
# to be commited before attempting to update the highest role.
# The exlusive lease will not be released after completion to prevent multiple jobs
# being executed during the defined timeout.
def update_highest_role
return unless update_highest_role?
run_after_commit_or_now do
lease_key = "update_highest_role:#{update_highest_role_attribute}"
lease = Gitlab::ExclusiveLease.new(lease_key, timeout: HIGHEST_ROLE_LEASE_TIMEOUT)
if lease.try_obtain
UpdateHighestRoleWorker.perform_in(HIGHEST_ROLE_JOB_DELAY, update_highest_role_attribute)
else
# use same logging as ExclusiveLeaseGuard
# rubocop:disable Gitlab/RailsLogger
Rails.logger.error('Cannot obtain an exclusive lease. There must be another instance already in execution.')
# rubocop:enable Gitlab/RailsLogger
end
end
end
end
...@@ -9,6 +9,7 @@ class Member < ApplicationRecord ...@@ -9,6 +9,7 @@ class Member < ApplicationRecord
include Presentable include Presentable
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
include FromUnion include FromUnion
include UpdateHighestRole
attr_accessor :raw_invite_token attr_accessor :raw_invite_token
...@@ -100,7 +101,6 @@ class Member < ApplicationRecord ...@@ -100,7 +101,6 @@ class Member < ApplicationRecord
after_destroy :destroy_notification_setting after_destroy :destroy_notification_setting
after_destroy :post_destroy_hook, unless: :pending? after_destroy :post_destroy_hook, unless: :pending?
after_commit :refresh_member_authorized_projects after_commit :refresh_member_authorized_projects
after_commit :update_highest_role
default_value_for :notification_level, NotificationSetting.levels[:global] default_value_for :notification_level, NotificationSetting.levels[:global]
...@@ -463,21 +463,15 @@ class Member < ApplicationRecord ...@@ -463,21 +463,15 @@ class Member < ApplicationRecord
end end
end end
# Triggers the service to schedule a Sidekiq job to update the highest role def update_highest_role?
# for a User
#
# The job will be called outside of a transaction in order to ensure the changes
# for a Member to be commited before attempting to update the highest role.
# rubocop: disable CodeReuse/ServiceClass
def update_highest_role
return unless user_id.present? return unless user_id.present?
return unless previous_changes[:access_level].present?
run_after_commit_or_now do previous_changes[:access_level].present?
Members::UpdateHighestRoleService.new(user_id).execute end
end
def update_highest_role_attribute
user_id
end end
# rubocop: enable CodeReuse/ServiceClass
end end
Member.prepend_if_ee('EE::Member') Member.prepend_if_ee('EE::Member')
...@@ -23,6 +23,7 @@ class User < ApplicationRecord ...@@ -23,6 +23,7 @@ class User < ApplicationRecord
include BatchDestroyDependentAssociations include BatchDestroyDependentAssociations
include HasUniqueInternalUsers include HasUniqueInternalUsers
include IgnorableColumns include IgnorableColumns
include UpdateHighestRole
DEFAULT_NOTIFICATION_LEVEL = :participating DEFAULT_NOTIFICATION_LEVEL = :participating
...@@ -238,7 +239,6 @@ class User < ApplicationRecord ...@@ -238,7 +239,6 @@ class User < ApplicationRecord
end end
end end
end end
after_commit :update_highest_role, on: [:create, :update]
after_initialize :set_projects_limit after_initialize :set_projects_limit
...@@ -1854,20 +1854,15 @@ class User < ApplicationRecord ...@@ -1854,20 +1854,15 @@ class User < ApplicationRecord
last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i
end end
# Triggers the service to schedule a Sidekiq job to update the highest role def update_highest_role?
# for a User return false unless persisted?
#
# The job will be called outside of a transaction in order to ensure the changes
# for a Member to be commited before attempting to update the highest role.
# rubocop: disable CodeReuse/ServiceClass
def update_highest_role
return unless (previous_changes.keys & %w(state user_type ghost)).any?
run_after_commit_or_now do (previous_changes.keys & %w(state user_type ghost)).any?
Members::UpdateHighestRoleService.new(id).execute end
end
def update_highest_role_attribute
id
end end
# rubocop: enable CodeReuse/ServiceClass
end end
User.prepend_if_ee('EE::User') User.prepend_if_ee('EE::User')
# frozen_string_literal: true
module Members
class UpdateHighestRoleService < ::BaseService
include ExclusiveLeaseGuard
LEASE_TIMEOUT = 10.minutes.to_i
DELAY = 10.minutes
attr_reader :user_id
def initialize(user_id)
@user_id = user_id
end
def execute
try_obtain_lease do
UpdateHighestRoleWorker.perform_in(DELAY, user_id)
end
end
private
def lease_key
"update_highest_role:#{user_id}"
end
def lease_timeout
LEASE_TIMEOUT
end
# Do not release the lease before the timeout to
# prevent multiple jobs being executed during the
# defined timeout
def lease_release?
false
end
end
end
---
title: Use concern instead of service to update highest role
merge_request: 28791
author:
type: other
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
describe Member do describe Member do
include ExclusiveLeaseHelpers
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
describe "Associations" do describe "Associations" do
...@@ -593,6 +595,9 @@ describe Member do ...@@ -593,6 +595,9 @@ describe Member do
end end
context 'when after_commit :update_highest_role' do context 'when after_commit :update_highest_role' do
let!(:user) { create(:user) }
let(:user_id) { user.id }
where(:member_type, :source_type) do where(:member_type, :source_type) do
:project_member | :project :project_member | :project
:group_member | :group :group_member | :group
...@@ -600,43 +605,34 @@ describe Member do ...@@ -600,43 +605,34 @@ describe Member do
with_them do with_them do
describe 'create member' do describe 'create member' do
it 'initializes a new Members::UpdateHighestRoleService object' do let!(:source) { create(source_type) }
source = create(source_type) # source owner initializes a new service object too
user = create(:user)
expect(Members::UpdateHighestRoleService).to receive(:new).with(user.id).and_call_original subject { create(member_type, :guest, user: user, source_type => source) }
create(member_type, :guest, user: user, source_type => source) include_examples 'update highest role with exclusive lease'
end
end end
context 'when member exists' do context 'when member exists' do
let!(:member) { create(member_type) } let!(:member) { create(member_type, user: user) }
describe 'update member' do describe 'update member' do
context 'when access level was changed' do context 'when access level was changed' do
it 'initializes a new Members::UpdateHighestRoleService object' do subject { member.update(access_level: Gitlab::Access::GUEST) }
expect(Members::UpdateHighestRoleService).to receive(:new).with(member.user_id).and_call_original
member.update(access_level: Gitlab::Access::GUEST) include_examples 'update highest role with exclusive lease'
end
end end
context 'when access level was not changed' do context 'when access level was not changed' do
it 'does not initialize a new Members::UpdateHighestRoleService object' do subject { member.update(notification_level: NotificationSetting.levels[:disabled]) }
expect(Members::UpdateHighestRoleService).not_to receive(:new).with(member.user_id)
member.update(notification_level: NotificationSetting.levels[:disabled]) include_examples 'does not update the highest role'
end
end end
end end
describe 'destroy member' do describe 'destroy member' do
it 'initializes a new Members::UpdateHighestRoleService object' do subject { member.destroy }
expect(Members::UpdateHighestRoleService).to receive(:new).with(member.user_id).and_call_original
member.destroy include_examples 'update highest role with exclusive lease'
end
end end
end end
end end
......
...@@ -5,6 +5,7 @@ require 'spec_helper' ...@@ -5,6 +5,7 @@ require 'spec_helper'
describe User, :do_not_mock_admin_mode do describe User, :do_not_mock_admin_mode do
include ProjectForksHelper include ProjectForksHelper
include TermsHelper include TermsHelper
include ExclusiveLeaseHelpers
it_behaves_like 'having unique enum values' it_behaves_like 'having unique enum values'
...@@ -4535,17 +4536,22 @@ describe User, :do_not_mock_admin_mode do ...@@ -4535,17 +4536,22 @@ describe User, :do_not_mock_admin_mode do
context 'when after_commit :update_highest_role' do context 'when after_commit :update_highest_role' do
describe 'create user' do describe 'create user' do
it 'initializes a new Members::UpdateHighestRoleService object' do subject { create(:user) }
expect_next_instance_of(Members::UpdateHighestRoleService) do |service|
expect(service).to receive(:execute) it 'schedules a job in the future', :aggregate_failures, :clean_gitlab_redis_shared_state do
allow_next_instance_of(Gitlab::ExclusiveLease) do |instance|
allow(instance).to receive(:try_obtain).and_return('uuid')
end end
create(:user) expect(UpdateHighestRoleWorker).to receive(:perform_in).and_call_original
expect { subject }.to change(UpdateHighestRoleWorker.jobs, :size).by(1)
end end
end end
context 'when user already exists' do context 'when user already exists' do
let!(:user) { create(:user) } let!(:user) { create(:user) }
let(:user_id) { user.id }
describe 'update user' do describe 'update user' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
...@@ -4560,24 +4566,24 @@ describe User, :do_not_mock_admin_mode do ...@@ -4560,24 +4566,24 @@ describe User, :do_not_mock_admin_mode do
with_them do with_them do
context 'when state was changed' do context 'when state was changed' do
it 'initializes a new Members::UpdateHighestRoleService object' do subject { user.update(attributes) }
expect_next_instance_of(Members::UpdateHighestRoleService) do |service|
expect(service).to receive(:execute)
end
user.update(attributes) include_examples 'update highest role with exclusive lease'
end
end end
end end
context 'when state was not changed' do context 'when state was not changed' do
it 'does not initialize a new Members::UpdateHighestRoleService object' do subject { user.update(email: 'newmail@example.com') }
expect(Members::UpdateHighestRoleService).not_to receive(:new)
user.update(email: 'newmail@example.com') include_examples 'does not update the highest role'
end
end end
end end
describe 'destroy user' do
subject { user.destroy }
include_examples 'does not update the highest role'
end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
require 'sidekiq/testing'
describe Members::UpdateHighestRoleService, :clean_gitlab_redis_shared_state do
include ExclusiveLeaseHelpers
let_it_be(:user) { create(:user) }
let_it_be(:lease_key) { "update_highest_role:#{user.id}" }
let(:service) { described_class.new(user.id) }
describe '#perform' do
subject { service.execute }
context 'when lease is obtained' do
it 'takes the lease but does not release it', :aggregate_failures do
expect_to_obtain_exclusive_lease(lease_key, 'uuid', timeout: described_class::LEASE_TIMEOUT)
subject
expect(service.exclusive_lease.exists?).to be_truthy
end
it 'schedules a job in the future', :aggregate_failures do
expect(UpdateHighestRoleWorker).to receive(:perform_in).with(described_class::DELAY, user.id).and_call_original
Sidekiq::Testing.fake! do
expect { subject }.to change(UpdateHighestRoleWorker.jobs, :size).by(1)
end
end
end
context 'when lease cannot be obtained' do
it 'only schedules one job' do
Sidekiq::Testing.fake! do
stub_exclusive_lease_taken(lease_key, timeout: described_class::LEASE_TIMEOUT)
expect { subject }.not_to change(UpdateHighestRoleWorker.jobs, :size)
end
end
end
end
end
# frozen_string_literal: true
# requires a subject and a user_id
RSpec.shared_examples 'update highest role with exclusive lease' do
include ExclusiveLeaseHelpers
let(:lease_key) { "update_highest_role:#{user_id}" }
before do
allow(Gitlab::ExclusiveLease).to receive(:new).and_call_original
end
context 'when lease is obtained', :clean_gitlab_redis_shared_state do
it 'takes the lease but does not release it', :aggregate_failures do
expect_to_obtain_exclusive_lease(lease_key, 'uuid', timeout: described_class::HIGHEST_ROLE_LEASE_TIMEOUT)
expect(Gitlab::ExclusiveLease).not_to receive(:cancel).with(lease_key, 'uuid')
subject
end
it 'schedules a job in the future', :aggregate_failures do
allow_next_instance_of(Gitlab::ExclusiveLease) do |instance|
allow(instance).to receive(:try_obtain).and_return('uuid')
end
expect(UpdateHighestRoleWorker).to receive(:perform_in).with(described_class::HIGHEST_ROLE_JOB_DELAY, user_id).and_call_original
expect { subject }.to change(UpdateHighestRoleWorker.jobs, :size).by(1)
end
end
context 'when lease cannot be obtained', :clean_gitlab_redis_shared_state do
it 'only schedules one job' do
stub_exclusive_lease_taken(lease_key, timeout: described_class::HIGHEST_ROLE_LEASE_TIMEOUT)
expect { subject }.not_to change(UpdateHighestRoleWorker.jobs, :size)
end
end
end
# requires a subject and a user_id
RSpec.shared_examples 'does not update the highest role' do
it 'does not obtain an exclusive lease' do
allow(Gitlab::ExclusiveLease).to receive(:new).and_call_original
lease = stub_exclusive_lease("update_highest_role:#{user_id}", 'uuid', timeout: described_class::HIGHEST_ROLE_LEASE_TIMEOUT)
expect(lease).not_to receive(:try_obtain)
subject
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