Fix N+1 queries in the group releases API

What does this MR do and why?

Releases::GroupReleasesFinder#execute calls releases.preloaded if preload and drops the relation that call returns (group_releases_finder.rb#L26), so Release.preloaded never applies. GET /groups/:id/releases asks for the preload (releases.rb#L452-456) and then loads the author, links, evidences, milestones and project of each release with queries of its own: about nine more queries for every release on the page. ReleasesFinder assigns the preloaded relation (releases_finder.rb#L25); this MR does the same.

The two specs meant to catch this could not:

  • The request spec "include_subgroups avoids N+1 queries" (releases_spec.rb#L1978-1995) requested an empty group while it created the new releases under group1, and it called a memoized subject, so only its first request was ever sent. It now sends every request, measures a group with one release in a subgroup project, adds three releases with different authors to that project, and checks that the last response lists all four.
  • The finder spec (group_releases_finder_spec.rb#L43) checked that Release.preloaded was called, which calling the scope on a relation does whether or not the result is used. It now checks that the associations of the returned releases are loaded.

The response is unchanged; only the number of queries changes. The other callers of the finder: the GraphQL group releases resolver uses the default preload: true and now gets the preload too, as the project releases resolver already does with ReleasesFinder; Groups::ReleasesController passes preload: false and is unaffected. No migrations.

The new releases in the request spec go to the same project as the first one on purpose. With releases in a second project, a few per-project and per-namespace queries remain (protected tags, the project namespace, the new subgroup); they grow with projects, not with releases, and are not what this MR changes.

app/finders/ needs a database approval. The main query of the finder (the keyset IN-operator query over the group's projects) does not change. What changes is how the associations are read: on master each release runs its own lookups, by its author_id, project_id or id; with this MR Release.preloaded runs one query per association over the ids of the whole page (IN, or = when there is a single id), the same queries it already runs for GET /projects/:id/releases through ReleasesFinder. Raw SQL of both versions is in the first details block below, and the specs I ran are in the second. I cannot use Database Lab, so I would ask the database reviewer for plans if they want them.

Raw SQL, before and after

Captured at the commit with a throwaway spec and ActiveRecord::QueryRecorder.new(skip_cached: false): a group with one subgroup project holding four releases, each with a milestone, an evidence and a link, reading what the API entity reads (author, project with its feature, route and namespace route, links, sorted links, evidences, milestones). The "before" is the finder's relation without the preload, which is what master returns. The main keyset query is the same in both and is left out, and the column lists of users, projects and namespaces are cut after their first column.

Before, 40 queries for four releases, these ten for each:

SELECT "users"."id", ... FROM find_users_by_id(854) AS users WHERE ("users"."id" IS NOT NULL) LIMIT 1
SELECT "projects"."id", ... FROM find_projects_by_id(328) AS projects WHERE ("projects"."id" IS NOT NULL) LIMIT 1
SELECT "project_features".* FROM "project_features" WHERE "project_features"."project_id" = 328 LIMIT 1
SELECT "routes".* FROM "routes" WHERE "routes"."source_id" = 328 AND "routes"."source_type" = 'Project' LIMIT 1
SELECT "namespaces"."id", ... FROM find_namespaces_by_id(657) AS namespaces WHERE ("namespaces"."id" IS NOT NULL) LIMIT 1
SELECT "routes".* FROM "routes" WHERE "routes"."source_id" = 657 AND "routes"."source_type" = 'Namespace' LIMIT 1
SELECT "release_links".* FROM "release_links" WHERE "release_links"."release_id" = 504
SELECT "release_links".* FROM "release_links" WHERE "release_links"."release_id" = 504 ORDER BY "release_links"."created_at" DESC
SELECT "evidences".* FROM "evidences" WHERE "evidences"."release_id" = 504 ORDER BY "evidences"."created_at" ASC
SELECT "milestones".* FROM "milestones" INNER JOIN "milestone_releases" ON "milestones"."id" = "milestone_releases"."milestone_id" WHERE "milestone_releases"."release_id" = 504

After, 11 queries whatever the number of releases:

SELECT "users"."id", ... FROM "users" WHERE "users"."id" IN (854, 853, 852, 851)
SELECT "evidences".* FROM "evidences" WHERE "evidences"."release_id" IN (504, 503, 502, 501) ORDER BY "evidences"."created_at" ASC
SELECT "milestone_releases".* FROM "milestone_releases" WHERE "milestone_releases"."release_id" IN (504, 503, 502, 501)
SELECT "milestones".* FROM "milestones" WHERE "milestones"."id" IN (31, 32, 33, 34)
SELECT "release_links".* FROM "release_links" WHERE "release_links"."release_id" IN (504, 503, 502, 501)
SELECT "release_links".* FROM "release_links" WHERE "release_links"."release_id" IN (504, 503, 502, 501) ORDER BY "release_links"."created_at" DESC
SELECT "projects"."id", ... FROM "projects" WHERE "projects"."id" = 328
SELECT "project_features".* FROM "project_features" WHERE "project_features"."project_id" = 328
SELECT "namespaces"."id", ... FROM "namespaces" WHERE "namespaces"."id" = 657
SELECT "routes".* FROM "routes" WHERE "routes"."source_type" = 'Project' AND "routes"."source_id" = 328
SELECT "routes".* FROM "routes" WHERE "routes"."source_type" = 'Namespace' AND "routes"."source_id" = 657
Specs I ran

On master at 797e8e3c with Ruby 3.3.11 and the project bundle:

  • spec/finders/releases/group_releases_finder_spec.rb without the fix: 12 examples, 1 failure (the new check, once per returned release); with it: 12 examples, 0 failures;
  • the two "include_subgroups avoids N+1 queries" examples without the fix: 2 examples, 1 failure (Expected a maximum of 46 queries, got 73, full entity; the simple: true entity passes both ways); with it: 2 examples, 0 failures;
  • the whole spec/requests/api/releases_spec.rb on this branch: 282 examples, 1 failure, 14 pending. The failure, POST /projects/:id/releases with the job token of a completed job, is a NoMethodError in the ActionCable subscription trigger and fails the same way with the fix reverted, so it comes from my local setup, not from this change;
  • the specs of the other callers of the finder, spec/graphql/resolvers/group_releases_resolver_spec.rb, spec/requests/api/graphql/group/group_releases_spec.rb and spec/controllers/groups/releases_controller_spec.rb: 40 examples, 0 failures;
  • preloading regardless of preload: makes the existing preload: false example fail, so the condition stays covered;
  • the console snippet under "How to set up and validate locally", run in a throwaway spec for one release and then four: 11 then 28 queries without the fix, 15 then 14 with it;
  • again on 2026-10-23, with master at acca25a8 changing none of these files, the finder spec and the two request examples gave the same results as on 797e8e3c: the same failures without the fix (the master body of #execute loaded over the commit) and the same passes with it;
  • bundle exec rubocop on the three changed files: no offenses; scripts/lint/commit_linter.rb on the commit: no findings.

Red and green output:

$ bundle exec rspec spec/finders/releases/group_releases_finder_spec.rb   # master
     1.1) Failure/Error: expect(associations.reject { |name| release.association(name).loaded? }).to be_empty
            expected `[:author, :evidences, :milestones, :links, :sorted_links, :project].empty?` to be truthy, got false
12 examples, 1 failure

$ bundle exec rspec spec/requests/api/releases_spec.rb:2005 spec/requests/api/releases_spec.rb:2006   # master
     Failure/Error:
       Expected a maximum of 46 queries, got 73:
2 examples, 1 failure

$ bundle exec rspec spec/finders/releases/group_releases_finder_spec.rb   # this branch
12 examples, 0 failures

$ bundle exec rspec spec/requests/api/releases_spec.rb:2005 spec/requests/api/releases_spec.rb:2006   # this branch
2 examples, 0 failures

References

Closes #636028 (closed)

  • !83295 (merged) is where the call that drops the result came in.
  • I found this while maintaining gitlab-mcp-server, which lists group releases through this endpoint; its upstream-bugs.md records everything done on its dependencies and sibling projects.

Screenshots or screen recordings

Not applicable: backend change with no UI.

How to set up and validate locally

  1. In a group, create a subgroup with a project in it, and one release in that project (for example with POST /projects/:id/releases).

  2. In a Rails console, with group set to that group and user to a user who can read its releases, count the queries that listing the group's releases and reading what the API entity reads takes:

    queries = 0
    counter = ->(*, payload) { queries += 1 unless payload[:name] == 'SCHEMA' || payload[:cached] }
    ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do
      Releases::GroupReleasesFinder.new(group, user).execute.each do |r|
        [r.author, r.project, r.links.to_a, r.sorted_links.to_a, r.evidences.to_a, r.milestones.to_a]
      end
    end
    queries
  3. Add three more releases to the same project and run the snippet again. On master the count grows with each release; on this branch it does not.

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.

Merge request reports

Loading
Loading