Improve performance of the work items REST API listing query

What does this MR do and why?

The REST work items list (GET /api/v4/namespaces/:full_path/-/work_items) is slower than the GraphQL list it replaced in the frontend. This MR closes part of that gap with two independent fixes found while investigating the difference: an N+1 query on milestone rendering, and redundant Ruby work in widget-support checks. The remaining largest REST-only cost in the profile is the per-item read_work_item authorization loop, which is out of scope here.

Preload milestone parents

The milestone feature preload was split by listing context: the group and namespace endpoints preloaded milestone: :group and the project endpoint preloaded milestone: :project. Neither matches what Milestone#web_url needs, and the split does not match the data. When listing a group with include_descendants=true, most work items belong to projects and carry project milestones, so each distinct milestone lazily loaded its project, the project's namespace, and the namespace's route. In a project listing, work items commonly carry milestones from ancestor groups (every gitlab-org/gitlab milestone is a group milestone), so each distinct milestone lazily loaded its group and the group's route. The GraphQL resolver already preloads both milestone parents, so this closes part of the gap between REST and GraphQL.

  • Move the milestone preload into the context-independent FEATURE_PRELOADS table as { milestone: [{ group: :route }, { project: { namespace: :route } }] } and drop the milestone entries from PROJECT_FEATURE_PRELOADS and GROUP_FEATURE_PRELOADS (lib/api/helpers/work_items/preloads.rb). ActiveRecord skips a belongs_to preload query when every foreign key in the batch is nil, so preloading both parents costs nothing when only one kind of milestone is present
  • Add request specs for the group listing (project milestones in descendant projects) and the project listing (group milestones on project work items) that assert the query count stays flat as more milestones are added, and check the milestone web_url values are correct

Memoize has_widget? on WorkItem

WorkItem inherited Issue#has_widget?, which calls work_item_type.widget_classes(resource_parent) and rebuilds the licensed and feature-flag-filtered widget list on every call. The REST list entity checks has_widget? once per requested feature per row (the guard in lib/api/entities/work_items/conditional_exposure_helpers.rb), so 12 features across 20 rows meant around 240 rebuilds per request, plus more in preload_hierarchy_authorization.

  • WorkItem now overrides has_widget? to read its already-memoized widget_definitions (app/models/work_item.rb). get_widget and widgets already memoize on the same instance, so no new staleness is introduced
  • Measured with stackprof, wall mode, warm cache, Bullet disabled, same request: has_widget? fell from 28% of request wall time to under 4%, and total samples fell from 310 to 236, roughly 24% less wall time
  • Verified equivalence for all 9 system-defined types x 33 widget types under project and group parents (594 checks, 0 mismatches). The only difference: an unknown widget name now returns false instead of raising NameError. Issue#has_widget? itself is unchanged, so plain Issue instances behave as before
  • Add a spec in spec/models/work_item_spec.rb, #has_widget? block: true for a supported widget, false for an unknown one, and confirms work_item_type.widgets resolves once across repeated has_widget? and get_widget calls

References

Screenshots or screen recordings

N/A, backend-only change.

How to set up and validate locally

  1. Run the milestone preload specs: bin/rspec ee/spec/requests/api/work_items_spec.rb -e 'N+1 query prevention for project milestones' -e 'N+1 query prevention for group milestones'
  2. Run the has_widget? spec: bin/rspec spec/models/work_item_spec.rb -e '#has_widget?'
  3. Optional manual check: as a user with access to a group that has subprojects with project milestones assigned to issues, call curl --header "PRIVATE-TOKEN: <token>" "http://gdk.test:3000/api/v4/namespaces/<group-full-path>/-/work_items?include_descendants=true&features=milestone&fields=author", and watch log/development.log to see that projects, namespaces, and routes are each loaded in one batch query rather than once per milestone. On master you'd see repeated per-milestone lookups instead.

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.

🤖 Generated with Claude Code

Edited by Matt D'Angelo

Merge request reports

Loading
Loading