Commit fe63f338 authored by Felipe's avatar Felipe Committed by Felipe Artur

Allow to block issues mass positioning

When imports or mass issue creation is happening we need a way to
disable issue placement and positions updates to stop hitting
the DB constantly.

Introduces block_issue_repositioning flag to block issue positioning.
parent b8d5962b
...@@ -27,7 +27,9 @@ module Boards ...@@ -27,7 +27,9 @@ module Boards
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params) list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service) issues = issues_from(list_service)
Issue.move_nulls_to_end(issues) if Gitlab::Database.read_write? if Gitlab::Database.read_write? && !board.disabled_for?(current_user)
Issue.move_nulls_to_end(issues)
end
render_issues(issues, list_service.metadata) render_issues(issues, list_service.metadata)
end end
......
...@@ -10,7 +10,7 @@ module BoardsHelper ...@@ -10,7 +10,7 @@ module BoardsHelper
boards_endpoint: @boards_endpoint, boards_endpoint: @boards_endpoint,
lists_endpoint: board_lists_path(board), lists_endpoint: board_lists_path(board),
board_id: board.id, board_id: board.id,
disabled: disabled?.to_s, disabled: board.disabled_for?(current_user).to_s,
root_path: root_path, root_path: root_path,
full_path: full_path, full_path: full_path,
bulk_update_path: @bulk_issues_path, bulk_update_path: @bulk_issues_path,
...@@ -101,10 +101,6 @@ module BoardsHelper ...@@ -101,10 +101,6 @@ module BoardsHelper
can?(current_user, :admin_issue, current_board_parent) can?(current_user, :admin_issue, current_board_parent)
end end
def disabled?
!can?(current_user, :create_non_backlog_issues, board)
end
def board_list_data def board_list_data
include_descendant_groups = @group&.present? include_descendant_groups = @group&.present?
......
...@@ -9,6 +9,22 @@ module IssuesHelper ...@@ -9,6 +9,22 @@ module IssuesHelper
classes.join(' ') classes.join(' ')
end end
def issue_manual_ordering_class
is_sorting_by_relative_position = @sort == 'relative_position'
if is_sorting_by_relative_position && !issue_repositioning_disabled?
"manual-ordering"
end
end
def issue_repositioning_disabled?
if @group
@group.root_ancestor.issue_repositioning_disabled?
elsif @project
@project.root_namespace.issue_repositioning_disabled?
end
end
def status_box_class(item) def status_box_class(item)
if item.try(:expired?) if item.try(:expired?)
'status-box-expired' 'status-box-expired'
......
...@@ -45,6 +45,12 @@ class Board < ApplicationRecord ...@@ -45,6 +45,12 @@ class Board < ApplicationRecord
def to_type def to_type
self.class.to_type self.class.to_type
end end
def disabled_for?(current_user)
namespace = group_board? ? resource_parent.root_ancestor : resource_parent.root_namespace
namespace.issue_repositioning_disabled? || !Ability.allowed?(current_user, :create_non_backlog_issues, self)
end
end end
Board.prepend_mod_with('Board') Board.prepend_mod_with('Board')
...@@ -79,6 +79,8 @@ module RelativePositioning ...@@ -79,6 +79,8 @@ module RelativePositioning
objects = objects.reject(&:relative_position) objects = objects.reject(&:relative_position)
return 0 if objects.empty? return 0 if objects.empty?
objects.first.check_repositioning_allowed!
number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each
representative = RelativePositioning.mover.context(objects.first) representative = RelativePositioning.mover.context(objects.first)
...@@ -123,6 +125,12 @@ module RelativePositioning ...@@ -123,6 +125,12 @@ module RelativePositioning
::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION)) ::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION))
end end
# To be overriden on child classes whenever
# blocking position updates is necessary.
def check_repositioning_allowed!
nil
end
def move_between(before, after) def move_between(before, after)
before, after = [before, after].sort_by(&:relative_position) if before && after before, after = [before, after].sort_by(&:relative_position) if before && after
......
...@@ -272,6 +272,18 @@ class Issue < ApplicationRecord ...@@ -272,6 +272,18 @@ class Issue < ApplicationRecord
"id DESC") "id DESC")
end end
# Temporary disable moving null elements because of performance problems
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
def check_repositioning_allowed!
if blocked_for_repositioning?
raise ::Gitlab::RelativePositioning::IssuePositioningDisabled, "Issue relative position changes temporarily disabled."
end
end
def blocked_for_repositioning?
resource_parent.root_namespace&.issue_repositioning_disabled?
end
def hook_attrs def hook_attrs
Gitlab::HookData::IssueBuilder.new(self).build Gitlab::HookData::IssueBuilder.new(self).build
end end
......
...@@ -420,6 +420,10 @@ class Namespace < ApplicationRecord ...@@ -420,6 +420,10 @@ class Namespace < ApplicationRecord
created_at >= 90.days.ago created_at >= 90.days.ago
end end
def issue_repositioning_disabled?
Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml)
end
private private
def all_projects_with_pages def all_projects_with_pages
......
...@@ -105,6 +105,8 @@ module Issues ...@@ -105,6 +105,8 @@ module Issues
end end
def handle_move_between_ids(issue) def handle_move_between_ids(issue)
issue.check_repositioning_allowed! if params[:move_between_ids]
super super
rebalance_if_needed(issue) rebalance_if_needed(issue)
......
= render 'shared/alerts/positioning_disabled'
= render "shared/boards/show", board: @board, group: true = render "shared/boards/show", board: @board, group: true
- is_project_overview = local_assigns.fetch(:is_project_overview, false) - is_project_overview = local_assigns.fetch(:is_project_overview, false)
= render 'shared/alerts/positioning_disabled'
- if Feature.enabled?(:vue_issuables_list, @project) && !is_project_overview - if Feature.enabled?(:vue_issuables_list, @project) && !is_project_overview
- data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id))) - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id)))
...@@ -15,7 +16,7 @@ ...@@ -15,7 +16,7 @@
'scoped-labels-available': scoped_labels_available?(@project).to_json } } 'scoped-labels-available': scoped_labels_available?(@project).to_json } }
- else - else
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues') - empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') } %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class }
= render partial: "projects/issues/issue", collection: @issues = render partial: "projects/issues/issue", collection: @issues
- if @issues.blank? - if @issues.blank?
= render empty_state_path = render empty_state_path
......
= render 'shared/alerts/positioning_disabled'
- if @issues.to_a.any? - if @issues.to_a.any?
%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } } %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class, data: { group_full_path: @group&.full_path } }
= render partial: 'projects/issues/issue', collection: @issues = render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab" = paginate @issues, theme: "gitlab"
- else - else
......
- if issue_repositioning_disabled?
= render 'shared/alert_info', body: _('Issues manual ordering is temporarily disabled for technical reasons.')
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
- breadcrumb_title _("Epic Boards") - breadcrumb_title _("Epic Boards")
- else - else
- breadcrumb_title _("Issue Boards") - breadcrumb_title _("Issue Boards")
= render 'shared/alerts/positioning_disabled'
- page_title("#{board.name}", _("Boards")) - page_title("#{board.name}", _("Boards"))
- add_page_specific_style 'page_bundles/boards' - add_page_specific_style 'page_bundles/boards'
......
...@@ -20,6 +20,10 @@ class IssuePlacementWorker ...@@ -20,6 +20,10 @@ class IssuePlacementWorker
issue = find_issue(issue_id, project_id) issue = find_issue(issue_id, project_id)
return unless issue return unless issue
# Temporary disable moving null elements because of performance problems
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
return if issue.blocked_for_repositioning?
# Move the oldest 100 unpositioned items to the end. # Move the oldest 100 unpositioned items to the end.
# This is to deal with out-of-order execution of the worker, # This is to deal with out-of-order execution of the worker,
# while preserving creation order. # while preserving creation order.
......
...@@ -14,6 +14,11 @@ class IssueRebalancingWorker ...@@ -14,6 +14,11 @@ class IssueRebalancingWorker
return if project_id.nil? return if project_id.nil?
project = Project.find(project_id) project = Project.find(project_id)
# Temporary disable reabalancing for performance reasons
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
return if project.root_namespace&.issue_repositioning_disabled?
# All issues are equivalent as far as we are concerned # All issues are equivalent as far as we are concerned
issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord
......
---
name: block_issue_repositioning
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60141
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329663
milestone: '13.12'
type: ops
group: group::project management
default_enabled: false
...@@ -10,6 +10,7 @@ module EE ...@@ -10,6 +10,7 @@ module EE
list_assignees_path: board_users_path(board, :json)) list_assignees_path: board_users_path(board, :json))
end end
# rubocop:disable Metrics/AbcSize
override :board_data override :board_data
def board_data def board_data
show_feature_promotion = @project && show_promotions? && show_feature_promotion = @project && show_promotions? &&
...@@ -36,12 +37,13 @@ module EE ...@@ -36,12 +37,13 @@ module EE
scoped_labels: current_board_parent.feature_available?(:scoped_labels)&.to_s, scoped_labels: current_board_parent.feature_available?(:scoped_labels)&.to_s,
can_update: can_update?.to_s, can_update: can_update?.to_s,
can_admin_list: can_admin_list?.to_s, can_admin_list: can_admin_list?.to_s,
disabled: disabled?.to_s, disabled: board.disabled_for?(current_user).to_s,
emails_disabled: current_board_parent.emails_disabled?.to_s emails_disabled: current_board_parent.emails_disabled?.to_s
} }
super.merge(data) super.merge(data)
end end
# rubocop:enable Metrics/AbcSize
override :can_update? override :can_update?
def can_update? def can_update?
...@@ -64,13 +66,6 @@ module EE ...@@ -64,13 +66,6 @@ module EE
super super
end end
override :disabled?
def disabled?
return false if board.is_a?(::Boards::EpicBoard)
super
end
override :recent_boards_path override :recent_boards_path
def recent_boards_path def recent_boards_path
return recent_group_boards_path(@group) if current_board_parent.is_a?(Group) return recent_group_boards_path(@group) if current_board_parent.is_a?(Group)
......
...@@ -64,5 +64,9 @@ module Boards ...@@ -64,5 +64,9 @@ module Boards
def weight def weight
nil nil
end end
def disabled_for?(current_user)
false
end
end end
end end
...@@ -15,6 +15,7 @@ module Gitlab ...@@ -15,6 +15,7 @@ module Gitlab
NoSpaceLeft = Class.new(StandardError) NoSpaceLeft = Class.new(StandardError)
InvalidPosition = Class.new(StandardError) InvalidPosition = Class.new(StandardError)
IllegalRange = Class.new(ArgumentError) IllegalRange = Class.new(ArgumentError)
IssuePositioningDisabled = Class.new(StandardError)
def self.range(lhs, rhs) def self.range(lhs, rhs)
if lhs && rhs if lhs && rhs
......
...@@ -18285,6 +18285,9 @@ msgstr "" ...@@ -18285,6 +18285,9 @@ msgstr ""
msgid "Issues closed" msgid "Issues closed"
msgstr "" msgstr ""
msgid "Issues manual ordering is temporarily disabled for technical reasons."
msgstr ""
msgid "Issues must match this scope to appear in this list." msgid "Issues must match this scope to appear in this list."
msgstr "" msgstr ""
......
...@@ -49,6 +49,7 @@ RSpec.describe Boards::IssuesController do ...@@ -49,6 +49,7 @@ RSpec.describe Boards::IssuesController do
create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow) create(:labeled_issue, project: project, labels: [development], due_date: Date.tomorrow)
create(:labeled_issue, project: project, labels: [development], assignees: [johndoe]) create(:labeled_issue, project: project, labels: [development], assignees: [johndoe])
issue.subscribe(johndoe, project) issue.subscribe(johndoe, project)
expect(Issue).to receive(:move_nulls_to_end)
list_issues user: user, board: board, list: list2 list_issues user: user, board: board, list: list2
...@@ -119,6 +120,18 @@ RSpec.describe Boards::IssuesController do ...@@ -119,6 +120,18 @@ RSpec.describe Boards::IssuesController do
expect(query_count).to eq(1) expect(query_count).to eq(1)
end end
context 'when block_issue_repositioning feature flag is enabled' do
before do
stub_feature_flags(block_issue_repositioning: true)
end
it 'does not reposition issues with null position' do
expect(Issue).not_to receive(:move_nulls_to_end)
list_issues(user: user, board: group_board, list: list3)
end
end
end end
context 'with invalid list id' do context 'with invalid list id' do
......
...@@ -341,4 +341,65 @@ RSpec.describe IssuesHelper do ...@@ -341,4 +341,65 @@ RSpec.describe IssuesHelper do
end end
end end
end end
describe '#issue_manual_ordering_class' do
context 'when sorting by relative position' do
before do
assign(:sort, 'relative_position')
end
it 'returns manual ordering class' do
expect(helper.issue_manual_ordering_class).to eq("manual-ordering")
end
context 'when manual sorting disabled' do
before do
allow(helper).to receive(:issue_repositioning_disabled?).and_return(true)
end
it 'returns nil' do
expect(helper.issue_manual_ordering_class).to eq(nil)
end
end
end
end
describe '#issue_repositioning_disabled?' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
subject { helper.issue_repositioning_disabled? }
context 'for project' do
before do
assign(:project, project)
end
it { is_expected.to eq(false) }
context 'when block_issue_repositioning feature flag is enabled' do
before do
stub_feature_flags(block_issue_repositioning: group)
end
it { is_expected.to eq(true) }
end
end
context 'for group' do
before do
assign(:group, group)
end
it { is_expected.to eq(false) }
context 'when block_issue_repositioning feature flag is enabled' do
before do
stub_feature_flags(block_issue_repositioning: group)
end
it { is_expected.to eq(true) }
end
end
end
end end
...@@ -42,4 +42,46 @@ RSpec.describe Board do ...@@ -42,4 +42,46 @@ RSpec.describe Board do
expect { project.boards.first_board.find(board_A.id) }.to raise_error(ActiveRecord::RecordNotFound) expect { project.boards.first_board.find(board_A.id) }.to raise_error(ActiveRecord::RecordNotFound)
end end
end end
describe '#disabled_for?' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:user) { create(:user) }
subject { board.disabled_for?(user) }
shared_examples 'board disabled_for?' do
context 'when current user cannot create non backlog issues' do
it { is_expected.to eq(true) }
end
context 'when user can create backlog issues' do
before do
board.resource_parent.add_reporter(user)
end
it { is_expected.to eq(false) }
context 'when block_issue_repositioning is enabled' do
before do
stub_feature_flags(block_issue_repositioning: group)
end
it { is_expected.to eq(true) }
end
end
end
context 'for group board' do
let_it_be(:board) { create(:board, group: group) }
it_behaves_like 'board disabled_for?'
end
context 'for project board' do
let_it_be(:board) { create(:board, project: project) }
it_behaves_like 'board disabled_for?'
end
end
end end
...@@ -1141,11 +1141,37 @@ RSpec.describe Issue do ...@@ -1141,11 +1141,37 @@ RSpec.describe Issue do
end end
context "relative positioning" do context "relative positioning" do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue1) { create(:issue, project: project, relative_position: nil) }
let_it_be(:issue2) { create(:issue, project: project, relative_position: nil) }
it_behaves_like "a class that supports relative positioning" do it_behaves_like "a class that supports relative positioning" do
let_it_be(:project) { reusable_project } let_it_be(:project) { reusable_project }
let(:factory) { :issue } let(:factory) { :issue }
let(:default_params) { { project: project } } let(:default_params) { { project: project } }
end end
it 'is not blocked for repositioning by default' do
expect(issue1.blocked_for_repositioning?).to eq(false)
end
context 'when block_issue_repositioning flag is enabled for group' do
before do
stub_feature_flags(block_issue_repositioning: group)
end
it 'is blocked for repositioning' do
expect(issue1.blocked_for_repositioning?).to eq(true)
end
it 'does not move issues with null position' do
payload = [issue1, issue2]
expect { described_class.move_nulls_to_end(payload) }.to raise_error(Gitlab::RelativePositioning::IssuePositioningDisabled)
expect { described_class.move_nulls_to_start(payload) }.to raise_error(Gitlab::RelativePositioning::IssuePositioningDisabled)
end
end
end end
it_behaves_like 'versioned description' it_behaves_like 'versioned description'
......
...@@ -165,20 +165,38 @@ RSpec.describe Issues::UpdateService, :mailer do ...@@ -165,20 +165,38 @@ RSpec.describe Issues::UpdateService, :mailer do
expect(user2.assigned_open_issues_count).to eq 1 expect(user2.assigned_open_issues_count).to eq 1
end end
it 'sorts issues as specified by parameters' do context 'when changing relative position' do
issue1 = create(:issue, project: project, assignees: [user3]) let(:issue1) { create(:issue, project: project, assignees: [user3]) }
issue2 = create(:issue, project: project, assignees: [user3]) let(:issue2) { create(:issue, project: project, assignees: [user3]) }
[issue, issue1, issue2].each do |issue| before do
issue.move_to_end [issue, issue1, issue2].each do |issue|
issue.save! issue.move_to_end
issue.save!
end
end end
opts[:move_between_ids] = [issue1.id, issue2.id] it 'sorts issues as specified by parameters' do
opts[:move_between_ids] = [issue1.id, issue2.id]
update_issue(opts) update_issue(opts)
expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position) expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
end
context 'when block_issue_positioning flag is enabled' do
before do
stub_feature_flags(block_issue_repositioning: true)
end
it 'raises error' do
old_position = issue.relative_position
opts[:move_between_ids] = [issue1.id, issue2.id]
expect { update_issue(opts) }.to raise_error(::Gitlab::RelativePositioning::IssuePositioningDisabled)
expect(issue.reload.relative_position).to eq(old_position)
end
end
end end
it 'does not rebalance even if needed if the flag is disabled' do it 'does not rebalance even if needed if the flag is disabled' do
......
...@@ -287,6 +287,12 @@ RSpec.configure do |config| ...@@ -287,6 +287,12 @@ RSpec.configure do |config|
# Selectively disable by actor https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor # Selectively disable by actor https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor
stub_feature_flags(remove_description_html_in_release_api_override: false) stub_feature_flags(remove_description_html_in_release_api_override: false)
# Disable issue respositioning to avoid heavy load on database when importing big projects.
# This is only turned on when app is handling heavy project imports.
# Can be removed when we find a better way to deal with the problem.
# For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
stub_feature_flags(block_issue_repositioning: false)
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else else
unstub_all_feature_flags unstub_all_feature_flags
......
...@@ -5,7 +5,8 @@ require 'spec_helper' ...@@ -5,7 +5,8 @@ require 'spec_helper'
RSpec.describe IssuePlacementWorker do RSpec.describe IssuePlacementWorker do
describe '#perform' do describe '#perform' do
let_it_be(:time) { Time.now.utc } let_it_be(:time) { Time.now.utc }
let_it_be(:project) { create(:project) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:author) { create(:user) } let_it_be(:author) { create(:user) }
let_it_be(:common_attrs) { { author: author, project: project } } let_it_be(:common_attrs) { { author: author, project: project } }
let_it_be(:unplaced) { common_attrs.merge(relative_position: nil) } let_it_be(:unplaced) { common_attrs.merge(relative_position: nil) }
...@@ -117,6 +118,19 @@ RSpec.describe IssuePlacementWorker do ...@@ -117,6 +118,19 @@ RSpec.describe IssuePlacementWorker do
let(:worker_arguments) { { issue_id: issue_id, project_id: nil } } let(:worker_arguments) { { issue_id: issue_id, project_id: nil } }
it_behaves_like 'running the issue placement worker' it_behaves_like 'running the issue placement worker'
context 'when block_issue_repositioning is enabled' do
let(:issue_id) { issue.id }
let(:project_id) { project.id }
before do
stub_feature_flags(block_issue_repositioning: group)
end
it 'does not run repositioning tasks' do
expect { run_worker }.not_to change { issue.reset.relative_position }
end
end
end end
context 'passing a project ID' do context 'passing a project ID' do
......
...@@ -4,7 +4,21 @@ require 'spec_helper' ...@@ -4,7 +4,21 @@ require 'spec_helper'
RSpec.describe IssueRebalancingWorker do RSpec.describe IssueRebalancingWorker do
describe '#perform' do describe '#perform' do
let_it_be(:issue) { create(:issue) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
context 'when block_issue_repositioning is enabled' do
before do
stub_feature_flags(block_issue_repositioning: group)
end
it 'does not run an instance of IssueRebalancingService' do
expect(IssueRebalancingService).not_to receive(:new)
described_class.new.perform(nil, issue.project_id)
end
end
it 'runs an instance of IssueRebalancingService' do it 'runs an instance of IssueRebalancingService' do
service = double(execute: nil) service = double(execute: nil)
......
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