Refresh blocking issues count when a group is deleted

What does this MR do and why?

Deleting a group removes its own work items (epics and other group-level work items) through the ON DELETE CASCADE foreign key on issues.namespace_id (fk_c78fbacd64), and their issue_links rows through the cascades on the link columns. No callback runs, so an issue outside the group that blocked one of them keeps counting it in issues.blocking_issues_count, which GraphQL blockingCount and the BLOCKING_ISSUES_* sorts read. !265408 (merged) fixed the same count for issue deletion and left this case for a follow-up; issues of the group's projects already go through it, since project deletion destroys them one by one.

EE::Groups::DestroyService#before_group_destroy, which runs for the group and each subgroup right before group.destroy, now reads the work items outside the group that block one of its open work items (WorkItems::RelatedWorkItemLink.blocking_ids_outside_namespace, by iid in batches of 100). Once the delete is committed, it enqueues WorkItems::UpdateBlockingIssuesCountWorker for every 100 of them, as destroy_associated_users does for bots, so a recount that fails is retried by Sidekiq and cannot stop the deletion. The worker calls WorkItem.update_blocking_issues_count_for, a class method of Issue where the recount loop of that MR moves so that both paths share it.

!258171 (closed), a draft, would delete these work items in committed batches right before group.destroy: a drain that runs over budget and resumes in a new job would miss the blockers of the work items it already drained, so the read stays right before group.destroy.

The new examples that expect a recount fail on master; all pass with the fix.

What I ran

On master at 9fc1d8a7 with these two commits, Ruby 3.3.11:

  • the new examples with master's three application files and no worker: 6 examples, 5 failures (the example that expects no recount of related or closed-only blockers passes, as it should);
  • with the fix, the EE and CE group deletion service specs, the link model spec, the worker spec and spec/workers/every_sidekiq_worker_spec.rb, whole: 154 examples, 0 failures, and the example of Issue.update_blocking_issues_count_for: 1 example, 0 failures;
  • three mutations of the fix (the enqueue before the commit, one job for every blocker, every state instead of open work items), each failing at least one new example;
  • gitlab:sidekiq:all_queues_yml:generate and gitlab:sidekiq:sidekiq_queues_yml:generate for the new queue, then both :check tasks;
  • RuboCop on the four Ruby files of the second commit, scripts/lint/commit_linter.rb and Danger's commit and title linters: no offenses.
$ bundle exec rspec ee/spec/services/groups/destroy_service_spec.rb:163 ee/spec/services/groups/destroy_service_spec.rb:243 ee/spec/models/ee/work_items/related_work_item_link_spec.rb:97 ee/spec/models/issue_spec.rb:1172   # master's application code, no worker
  1) Groups::DestroyService when work items of the group are blocked by issues outside it enqueues the recount in batches once each group is deleted
       expected [] to match [{:ids=>[484], :batch_sizes=>[1], :work_items_left=>[488]}, {:ids=>(contain exactly 481, 482, and 483), :batch_sizes=>[2, 1], :work_items_left=>[]}]
  2) Groups::DestroyService when work items of the group are blocked by issues outside it refreshes the blocking issues count of the issues outside the group that blocked its work items
       expected `blocking_issue.reload.blocking_issues_count` to have changed from 2 to 1, but did not change
       expected `closed_blocking_issue.reload.blocking_issues_count` to have changed from 1 to 0, but did not change
       expected `blocking_epic.reload.blocking_issues_count` to have changed from 1 to 0, but did not change
       expected `subgroup_blocker.reload.blocking_issues_count` to have changed from 1 to 0, but did not change
  3) Groups::DestroyService when work items of the group are blocked only by work items of the group enqueues no recount
       NameError: uninitialized constant WorkItems::UpdateBlockingIssuesCountWorker
  4) WorkItems::RelatedWorkItemLink.blocking_ids_outside_namespace returns the work items outside the namespace that block its open work items, read in batches
       NoMethodError: undefined method `blocking_ids_outside_namespace' for class WorkItems::RelatedWorkItemLink
  5) Issue.update_blocking_issues_count_for recounts every given issue, in batches
       NoMethodError: undefined method `update_blocking_issues_count_for' for class Issue
6 examples, 5 failures

No migration. The Database Lab plans of the read below, on production, are in !266033 (comment 4122219896).

Database: the SQL a group deletion now runs

Recorded from a spec on this branch: a group (id 433) with two open work items, one blocked by two issues of a project outside it. Before group.destroy, the group's open work items are batched by iid (index_issues_on_namespace_id_iid_unique) and each batch of 100 runs one read (index_issue_links_on_target_id covers target_id; the limit is 100 work items with at most 100 links each):

SELECT "issues"."iid" FROM "issues" WHERE "issues"."namespace_id" = 433 AND "issues"."state_id" = 1 ORDER BY "issues"."iid" ASC LIMIT 1
SELECT "issues"."iid" FROM "issues" WHERE "issues"."namespace_id" = 433 AND "issues"."state_id" = 1 AND "issues"."iid" >= 1 ORDER BY "issues"."iid" ASC LIMIT 1 OFFSET 100
SELECT "issue_links"."source_id" FROM "issue_links" INNER JOIN "issues" "source" ON "source"."id" = "issue_links"."source_id" WHERE "issue_links"."link_type" = 1 AND "issue_links"."target_id" IN (SELECT "issues"."id" FROM "issues" WHERE "issues"."namespace_id" = 433 AND "issues"."state_id" = 1 AND "issues"."iid" >= 1) AND "source"."namespace_id" != 433 LIMIT 10000

A batch that is not the last also bounds the subquery with "issues"."iid" < <next iid>. Nothing else runs when no batch returns a row. Otherwise each job of the worker, enqueued after the commit, reads its 100 blockers (column list shortened to *):

SELECT "issues".* FROM "issues" WHERE "issues"."id" IN (173, 174) ORDER BY "issues"."id" ASC LIMIT 1000

and for each blocker the statements update_blocking_issues_count! already runs when a link is added or removed: this count, then its UPDATE of blocking_issues_count when the count changed.

SELECT COUNT(CASE WHEN issues.state_id = 1 then 1 else null end), issue_links.source_id AS blocking_issue_id FROM "issue_links" INNER JOIN "issues" ON "issues"."id" = "issue_links"."target_id" WHERE "issue_links"."link_type" = 1 AND "issue_links"."source_id" = 173 GROUP BY blocking_issue_id

References

Closes #636848 (closed)

Screenshots or screen recordings

Not applicable: backend change, no UI change.

How to set up and validate locally

  1. On Ultimate, create groups G and H, with an epic in each.
  2. On H's epic, add G's epic to its linked items as blocks.
  3. In GraphiQL, run { namespace(fullPath: "<H>") { workItem(iid: "<iid>") { features { linkedItems { blockingCount } } } } }. It answers 1.
  4. Delete G permanently.
  5. Run the query again: 1 on master, 0 on this branch.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by José M. Requena Plens

Merge request reports

Loading
Loading