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 ofIssue.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:generateandgitlab:sidekiq:sidekiq_queues_yml:generatefor the new queue, then both:checktasks;- RuboCop on the four Ruby files of the second commit,
scripts/lint/commit_linter.rband 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 failuresNo 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 10000A 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 1000and 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_idReferences
Closes #636848 (closed)
- #636333 (closed) was the issue deletion case.
- #636694 (closed) is about repairing the counts that are already wrong.
- Found while working on gitlab-mcp-server, which reads
blocking_issues_countthrough the REST API.
Screenshots or screen recordings
Not applicable: backend change, no UI change.
How to set up and validate locally
- On Ultimate, create groups G and H, with an epic in each.
- On H's epic, add G's epic to its linked items as blocks.
- In GraphiQL, run
{ namespace(fullPath: "<H>") { workItem(iid: "<iid>") { features { linkedItems { blockingCount } } } } }. It answers1. - Delete G permanently.
- Run the query again:
1onmaster,0on 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.