diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 08524f739d2cc7fca0fef88b50756ec12595b0b1..478c7cd156ff5b5e3029c5fcfb47f22e10b67c95 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -65,7 +65,7 @@ def award_emojis_loaded? has_many :label_links, as: :target, dependent: :destroy, inverse_of: :target # rubocop:disable Cop/ActiveRecordDependent has_many :labels, through: :label_links - has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :todos, as: :target has_one :metrics, inverse_of: model_name.singular.to_sym, autosave: true diff --git a/app/models/user.rb b/app/models/user.rb index a0b23429f9fb0cc69ccba6f226736d95c65888c6..a4b0f96e97559d686b19db1c276ca5ce86fc962e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1668,8 +1668,7 @@ def update_todos_count_cache def invalidate_cache_counts invalidate_issue_cache_counts invalidate_merge_request_cache_counts - invalidate_todos_done_count - invalidate_todos_pending_count + invalidate_todos_cache_counts invalidate_personal_projects_count end @@ -1682,11 +1681,8 @@ def invalidate_merge_request_cache_counts Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count']) end - def invalidate_todos_done_count + def invalidate_todos_cache_counts Rails.cache.delete(['users', id, 'todos_done_count']) - end - - def invalidate_todos_pending_count Rails.cache.delete(['users', id, 'todos_pending_count']) end diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb index 4c64655a622f2ef404d000604b08538981c13979..b2458aec0b1df2ea3103b7256dbcd538fa09f048 100644 --- a/app/services/issuable/destroy_service.rb +++ b/app/services/issuable/destroy_service.rb @@ -3,11 +3,25 @@ module Issuable class DestroyService < IssuableBaseService def execute(issuable) - TodoService.new.destroy_target(issuable) do |issuable| - if issuable.destroy - issuable.update_project_counter_caches - issuable.assignees.each(&:invalidate_cache_counts) - end + if issuable.destroy + delete_todos(issuable) + issuable.update_project_counter_caches + issuable.assignees.each(&:invalidate_cache_counts) + end + end + + private + + def delete_todos(issuable) + actor = issuable.is_a?(Epic) ? issuable.resource_parent : issuable.resource_parent.group + + if Feature.enabled?(:destroy_issuable_todos_async, actor, default_enabled: :yaml) + TodosDestroyer::DestroyedIssuableWorker + .perform_async(issuable.id, issuable.class.name) + else + TodosDestroyer::DestroyedIssuableWorker + .new + .perform(issuable.id, issuable.class.name) end end end diff --git a/app/services/todos/destroy/destroyed_issuable_service.rb b/app/services/todos/destroy/destroyed_issuable_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..db12965224b39cba4da1dd4bc77bd5782efb9867 --- /dev/null +++ b/app/services/todos/destroy/destroyed_issuable_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Todos + module Destroy + class DestroyedIssuableService + BATCH_SIZE = 100 + + def initialize(target_id, target_type) + @target_id = target_id + @target_type = target_type + end + + def execute + inner_query = Todo.select(:id).for_target(target_id).for_type(target_type).limit(BATCH_SIZE) + + delete_query = <<~SQL + DELETE FROM "#{Todo.table_name}" + WHERE id IN (#{inner_query.to_sql}) + RETURNING user_id + SQL + + loop do + result = ActiveRecord::Base.connection.execute(delete_query) + + break if result.cmd_tuples == 0 + + user_ids = result.map { |row| row['user_id'] }.uniq + + invalidate_todos_cache_counts(user_ids) + end + end + + private + + attr_reader :target_id, :target_type + + def invalidate_todos_cache_counts(user_ids) + user_ids.each do |id| + # Only build a user instance since we only need its ID for + # `User#invalidate_todos_cache_counts` to work. + User.new(id: id).invalidate_todos_cache_counts + end + end + end + end +end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 24c96095ccc16a7fa8e6dbe09f9f0b0e87d080bd..532e49410727403de0e44b33824143046999f14a 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1411,6 +1411,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: todos_destroyer:todos_destroyer_destroyed_issuable + :feature_category: :issue_tracking + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: todos_destroyer:todos_destroyer_entity_leave :feature_category: :issue_tracking :has_external_dependencies: diff --git a/app/workers/todos_destroyer/destroyed_issuable_worker.rb b/app/workers/todos_destroyer/destroyed_issuable_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..6ca1959ff3452467365b4e2cbaef9bd842f2211d --- /dev/null +++ b/app/workers/todos_destroyer/destroyed_issuable_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module TodosDestroyer + class DestroyedIssuableWorker + include ApplicationWorker + include TodosDestroyerQueue + + idempotent! + + def perform(target_id, target_type) + ::Todos::Destroy::DestroyedIssuableService.new(target_id, target_type).execute + end + end +end diff --git a/changelogs/unreleased/325689-delete-issuable-todos-async.yml b/changelogs/unreleased/325689-delete-issuable-todos-async.yml new file mode 100644 index 0000000000000000000000000000000000000000..b8335448bafce4180e1a2ed12bdf1779da092235 --- /dev/null +++ b/changelogs/unreleased/325689-delete-issuable-todos-async.yml @@ -0,0 +1,5 @@ +--- +title: Delete all issuable todos asynchronously when issuable is destroyed +merge_request: 57830 +author: +type: performance diff --git a/config/feature_flags/development/destroy_issuable_todos_async.yml b/config/feature_flags/development/destroy_issuable_todos_async.yml new file mode 100644 index 0000000000000000000000000000000000000000..c39e551bdd9909dc681e1cc6559a02173177db78 --- /dev/null +++ b/config/feature_flags/development/destroy_issuable_todos_async.yml @@ -0,0 +1,8 @@ +--- +name: destroy_issuable_todos_async +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57830 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/325689 +milestone: '13.11' +type: development +group: group::code review +default_enabled: false diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 5be98dbb096289e318ac0408d41b216671e1ab2f..530db0537eb58f2ee000a14aef56fa553e004132 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1518,12 +1518,6 @@ def post_spam expect(response).to have_gitlab_http_status(:unprocessable_entity) expect(json_response).to eq({ 'errors' => 'Destroy confirmation not provided for issue' }) end - - it 'delegates the update of the todos count cache to TodoService' do - expect_any_instance_of(TodoService).to receive(:destroy_target).with(issue).once - - delete :destroy, params: { namespace_id: project.namespace, project_id: project, id: issue.iid, destroy_confirm: true } - end end end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 82b969cad40f3ee3c9362212030c9939a50365fa..337a4a19b2ec5ff444121a8196d177ea2903738b 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -728,12 +728,6 @@ def merge_when_pipeline_succeeds expect(response).to have_gitlab_http_status(:unprocessable_entity) expect(json_response).to eq({ 'errors' => 'Destroy confirmation not provided for merge request' }) end - - it 'delegates the update of the todos count cache to TodoService' do - expect_any_instance_of(TodoService).to receive(:destroy_target).with(merge_request).once - - delete :destroy, params: { namespace_id: project.namespace, project_id: project, id: merge_request.iid, destroy_confirm: true } - end end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 07f3efd70f6c4344975f4e8c48b313923c5617e7..1df70f387076948e022323de0aced28179a69608 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -16,7 +16,7 @@ it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:author) } it { is_expected.to have_many(:notes).dependent(:destroy) } - it { is_expected.to have_many(:todos).dependent(:destroy) } + it { is_expected.to have_many(:todos) } it { is_expected.to have_many(:labels) } it { is_expected.to have_many(:note_authors).through(:notes) } diff --git a/spec/services/issuable/destroy_service_spec.rb b/spec/services/issuable/destroy_service_spec.rb index 8d62932f9867abb14bc764335f9d579f5789754e..fadab77a04328a30996d38c9b35aeac47d462ce8 100644 --- a/spec/services/issuable/destroy_service_spec.rb +++ b/spec/services/issuable/destroy_service_spec.rb @@ -9,6 +9,32 @@ subject(:service) { described_class.new(project, user) } describe '#execute' do + shared_examples_for 'service deleting todos' do + it 'destroys associated todos asynchronously' do + expect(TodosDestroyer::DestroyedIssuableWorker) + .to receive(:perform_async) + .with(issuable.id, issuable.class.name) + + subject.execute(issuable) + end + + context 'when destroy_issuable_todos_async feature is disabled' do + before do + stub_feature_flags(destroy_issuable_todos_async: false) + end + + it 'destroy associated todos synchronously' do + expect_next_instance_of(TodosDestroyer::DestroyedIssuableWorker) do |worker| + expect(worker) + .to receive(:perform) + .with(issuable.id, issuable.class.name) + end + + subject.execute(issuable) + end + end + end + context 'when issuable is an issue' do let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) } @@ -22,17 +48,14 @@ service.execute(issue) end - it 'updates the todo caches for users with todos on the issue' do - create(:todo, target: issue, user: user, author: user, project: project) - - expect { service.execute(issue) } - .to change { user.todos_pending_count }.from(1).to(0) - end - it 'invalidates the issues count cache for the assignees' do expect_any_instance_of(User).to receive(:invalidate_cache_counts).once service.execute(issue) end + + it_behaves_like 'service deleting todos' do + let(:issuable) { issue } + end end context 'when issuable is a merge request' do @@ -53,11 +76,8 @@ service.execute(merge_request) end - it 'updates the todo caches for users with todos on the merge request' do - create(:todo, target: merge_request, user: user, author: user, project: project) - - expect { service.execute(merge_request) } - .to change { user.todos_pending_count }.from(1).to(0) + it_behaves_like 'service deleting todos' do + let(:issuable) { merge_request } end end end diff --git a/spec/services/todos/destroy/destroyed_issuable_service_spec.rb b/spec/services/todos/destroy/destroyed_issuable_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..24f74bae7c8afbed267d6c54134ffe0c02c3b387 --- /dev/null +++ b/spec/services/todos/destroy/destroyed_issuable_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Todos::Destroy::DestroyedIssuableService do + describe '#execute' do + let_it_be(:target) { create(:merge_request) } + let_it_be(:pending_todo) { create(:todo, :pending, project: target.project, target: target, user: create(:user)) } + let_it_be(:done_todo) { create(:todo, :done, project: target.project, target: target, user: create(:user)) } + + def execute + described_class.new(target.id, target.class.name).execute + end + + it 'deletes todos for specified target ID and type' do + control_count = ActiveRecord::QueryRecorder.new { execute }.count + + # Create more todos for the target + create(:todo, :pending, project: target.project, target: target, user: create(:user)) + create(:todo, :pending, project: target.project, target: target, user: create(:user)) + create(:todo, :done, project: target.project, target: target, user: create(:user)) + create(:todo, :done, project: target.project, target: target, user: create(:user)) + + expect { execute }.not_to exceed_query_limit(control_count) + expect(target.reload.todos.count).to eq(0) + end + + it 'invalidates todos cache counts of todo users', :use_clean_rails_redis_caching do + expect { execute } + .to change { pending_todo.user.todos_pending_count }.from(1).to(0) + .and change { done_todo.user.todos_done_count }.from(1).to(0) + end + end +end diff --git a/spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb b/spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6ccad25ad76a5f59cc4159c97ba1693918051367 --- /dev/null +++ b/spec/workers/todos_destroyer/destroyed_issuable_worker_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TodosDestroyer::DestroyedIssuableWorker do + let(:job_args) { [1, 'MergeRequest'] } + + it 'calls the Todos::Destroy::DestroyedIssuableService' do + expect_next_instance_of(::Todos::Destroy::DestroyedIssuableService, *job_args) do |service| + expect(service).to receive(:execute) + end + + described_class.new.perform(*job_args) + end +end