diff options
Diffstat (limited to 'app')
-rw-r--r-- | app/models/project_authorization.rb | 13 | ||||
-rw-r--r-- | app/models/user.rb | 38 | ||||
-rw-r--r-- | app/services/users/refresh_authorized_projects_service.rb | 128 | ||||
-rw-r--r-- | app/workers/authorized_projects_worker.rb | 22 |
4 files changed, 152 insertions, 49 deletions
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index a00d43773d9..4c7f4f5a429 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -5,4 +5,17 @@ class ProjectAuthorization < ActiveRecord::Base validates :project, presence: true validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true + + def self.insert_authorizations(rows, per_batch = 1000) + rows.each_slice(per_batch) do |slice| + tuples = slice.map do |tuple| + tuple.map { |value| connection.quote(value) } + end + + connection.execute <<-EOF.strip_heredoc + INSERT INTO project_authorizations (user_id, project_id, access_level) + VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + EOF + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 3a17c98eff6..9f5cc149361 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -443,22 +443,16 @@ class User < ActiveRecord::Base end def refresh_authorized_projects - transaction do - project_authorizations.delete_all - - # project_authorizations_union can return multiple records for the same - # project/user with different access_level so we take row with the maximum - # access_level - project_authorizations.connection.execute <<-SQL - INSERT INTO project_authorizations (user_id, project_id, access_level) - SELECT user_id, project_id, MAX(access_level) AS access_level - FROM (#{project_authorizations_union.to_sql}) sub - GROUP BY user_id, project_id - SQL - - unless authorized_projects_populated - update_column(:authorized_projects_populated, true) - end + Users::RefreshAuthorizedProjectsService.new(self).execute + end + + def remove_project_authorizations(project_ids) + project_authorizations.where(id: project_ids).delete_all + end + + def set_authorized_projects_column + unless authorized_projects_populated + update_column(:authorized_projects_populated, true) end end @@ -905,18 +899,6 @@ class User < ActiveRecord::Base private - # Returns a union query of projects that the user is authorized to access - def project_authorizations_union - relations = [ - personal_projects.select("#{id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"), - groups_projects.select_for_project_authorization, - projects.select_for_project_authorization, - groups.joins(:shared_projects).select_for_project_authorization - ] - - Gitlab::SQL::Union.new(relations) - end - def ci_projects_union scope = { access_level: [Gitlab::Access::MASTER, Gitlab::Access::OWNER] } groups = groups_projects.where(members: scope) diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb new file mode 100644 index 00000000000..7d38ac3a374 --- /dev/null +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -0,0 +1,128 @@ +module Users + # Service for refreshing the authorized projects of a user. + # + # This particular service class can not be used to update data for the same + # user concurrently. Doing so could lead to an incorrect state. To ensure this + # doesn't happen a caller must synchronize access (e.g. using + # `Gitlab::ExclusiveLease`). + # + # Usage: + # + # user = User.find_by(username: 'alice') + # service = Users::RefreshAuthorizedProjectsService.new(some_user) + # service.execute + class RefreshAuthorizedProjectsService + attr_reader :user + + LEASE_TIMEOUT = 1.minute.to_i + + # user - The User for which to refresh the authorized projects. + def initialize(user) + @user = user + + # We need an up to date User object that has access to all relations that + # may have been created earlier. The only way to ensure this is to reload + # the User object. + user.reload + end + + # This method returns the updated User object. + def execute + current = current_authorizations_per_project + fresh = fresh_access_levels_per_project + + remove = current.each_with_object([]) do |(project_id, row), array| + # rows not in the new list or with a different access level should be + # removed. + if !fresh[project_id] || fresh[project_id] != row.access_level + array << row.id + end + end + + add = fresh.each_with_object([]) do |(project_id, level), array| + # rows not in the old list or with a different access level should be + # added. + if !current[project_id] || current[project_id].access_level != level + array << [user.id, project_id, level] + end + end + + update_with_lease(remove, add) + end + + # Updates the list of authorizations using an exclusive lease. + def update_with_lease(remove = [], add = []) + lease_key = "refresh_authorized_projects:#{user.id}" + lease = Gitlab::ExclusiveLease.new(lease_key, timeout: LEASE_TIMEOUT) + + until uuid = lease.try_obtain + # Keep trying until we obtain the lease. If we don't do so we may end up + # not updating the list of authorized projects properly. To prevent + # hammering Redis too much we'll wait for a bit between retries. + sleep(1) + end + + begin + update_authorizations(remove, add) + ensure + Gitlab::ExclusiveLease.cancel(lease_key, uuid) + end + end + + # Updates the list of authorizations for the current user. + # + # remove - The IDs of the authorization rows to remove. + # add - Rows to insert in the form `[user id, project id, access level]` + def update_authorizations(remove = [], add = []) + return if remove.empty? && add.empty? + + User.transaction do + user.remove_project_authorizations(remove) unless remove.empty? + ProjectAuthorization.insert_authorizations(add) unless add.empty? + user.set_authorized_projects_column + end + + # Since we batch insert authorization rows, Rails' associations may get + # out of sync. As such we force a reload of the User object. + user.reload + end + + def fresh_access_levels_per_project + fresh_authorizations.each_with_object({}) do |row, hash| + hash[row.project_id] = row.access_level + end + end + + def current_authorizations_per_project + current_authorizations.each_with_object({}) do |row, hash| + hash[row.project_id] = row + end + end + + def current_authorizations + user.project_authorizations.select(:id, :project_id, :access_level) + end + + def fresh_authorizations + ProjectAuthorization. + unscoped. + select('project_id, MAX(access_level) AS access_level'). + from("(#{project_authorizations_union.to_sql}) #{ProjectAuthorization.table_name}"). + group(:project_id) + end + + private + + # Returns a union query of projects that the user is authorized to access + def project_authorizations_union + relations = [ + user.personal_projects.select("#{user.id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"), + user.groups_projects.select_for_project_authorization, + user.projects.select_for_project_authorization, + user.groups.joins(:shared_projects).select_for_project_authorization + ] + + Gitlab::SQL::Union.new(relations) + end + end +end diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index fccddb70d18..2badd0680fb 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -2,8 +2,6 @@ class AuthorizedProjectsWorker include Sidekiq::Worker include DedicatedSidekiqQueue - LEASE_TIMEOUT = 1.minute.to_i - def self.bulk_perform_async(args_list) Sidekiq::Client.push_bulk('class' => self, 'args' => args_list) end @@ -11,24 +9,6 @@ class AuthorizedProjectsWorker def perform(user_id) user = User.find_by(id: user_id) - refresh(user) if user - end - - def refresh(user) - lease_key = "refresh_authorized_projects:#{user.id}" - lease = Gitlab::ExclusiveLease.new(lease_key, timeout: LEASE_TIMEOUT) - - until uuid = lease.try_obtain - # Keep trying until we obtain the lease. If we don't do so we may end up - # not updating the list of authorized projects properly. To prevent - # hammering Redis too much we'll wait for a bit between retries. - sleep(1) - end - - begin - user.refresh_authorized_projects - ensure - Gitlab::ExclusiveLease.cancel(lease_key, uuid) - end + user.refresh_authorized_projects if user end end |