Batch GraphQL containerRepositoriesCount to avoid N+1
What does this MR do and why?
Types::ProjectType#container_repositories_count resolved containerRepositoriesCount as project.container_repositories.size. ActiveRecord::Associations::CollectionProxy#size does not memoize, so every time the field is resolved it issues a fresh SELECT COUNT(*). When a query reaches this field once per node under containerRepositories (e.g. containerRepositories { nodes { project { containerRepositoriesCount } } }), the count re-runs once per node, so the query count grows linearly with page size.
This was flagged by N+1 triage on the FetchProjectContainerRepositories operation (an external client's operation, not defined by a .graphql file in this repo): 2,961 requests in 24h, N+1 risk 0.95, p95 422 queries on the main replica, p95 DB duration 0.28s, estimated saving of 20.5 avoidable queries/minute.
Even though Postgres' per-request query cache can serve the repeated identical COUNT(*) queries, they still count towards the db_*_count metrics that feed the p95 figure above (see lib/gitlab/metrics/subscribers/active_record.rb, where :count is incremented regardless of cache hit; cached_count is a separate, additional counter). So the repeats are real, measured load even when Postgres itself does little extra work for them.
Fix, following the existing AlertManagement::Alert.counts_by_project_id pattern:
- Added
ContainerRepository.counts_by_project_id(reorder(nil).group(:project_id).count) inapp/models/container_repository.rb. Thereorder(nil)matchesIssue.counts_by_stateand keeps the scope safe to chain after an ordered scope:ContainerRepositoryincludesSortableand hasscope :ordered, and without itContainerRepository.ordered.counts_by_project_idraisesPG::GroupingError. container_repositories_countinapp/graphql/types/project_type.rbnow resolves throughBatchLoader::GraphQL.for(project.id).batch(default_value: 0), callingContainerRepository.for_project_id(project_ids).counts_by_project_idonce per request no matter how many times the field is reached.default_value: 0preserves the existingnull: falsecontract for projects without container repositories.- Removed the now-redundant
container_repositories_count: [:container_repositories]lookahead preload inapp/graphql/resolvers/concerns/projects/look_ahead_preloads.rb. It used to preload every container repository row just to count them; the batch loader replaces that with a single grouped count. Thecontainer_repositories: [:container_repositories]entry, used for the connection field itself, is untouched.
Measured locally with ActiveRecord::QueryRecorder.new(skip_cached: false) (this matches the production metric, since it also counts cached queries):
Scenario A — the reported shape, project { containerRepositories(first: N) { nodes { id name path project { id containerRepositoriesCount } } } }, one project with 20 container repositories, a different user per request:
| page size | before | after |
|---|---|---|
| 2 nodes | 48 queries | 47 queries |
| 20 nodes | 65 queries | 46 queries |
| growth 2 -> 20 | +17 | none |
The per-row SELECT COUNT(*) FROM container_repositories WHERE project_id = ? went from 20 (one per node) to 0; a single grouped count runs instead.
Scenario B — group { projects(first: N) { nodes { id containerRepositoriesCount } } }, 20 projects with one container repository each. This shape was already flat before the fix (the lookahead preload already covered it): 52 queries at first=2 and 51 at first=20, unchanged before and after. The fix doesn't reduce the query count here, but it replaces preloading every container repository row into memory with a single grouped COUNT.
Also identified while triaging this issue, but not fixed in this MR:
- Under the same nested-project selection,
jiraImportStatusalso re-runs once per node (SELECT ... FROM jira_imports ... ORDER BY created_at DESC LIMIT 1, +18 queries at page 20). That's owned by Category:Importers, not Category:Container Registry, and is listed separately on the issue. - The group-level
group { containerRepositories }path has its own N+1: rows are loaded viaContainerRepository.for_group_and_its_subgroups, which doesn't setinverse_of, so each row loads its ownProjectandroute. Also listed on the issue, not addressed here. Types::GroupType#container_repositories_countstill usesgroup.container_repositories.sizewith a matching lookahead preload inapp/graphql/resolvers/concerns/resolves_groups.rb, so the group-level field keeps the pattern this MR removes for projects. It needs a different query (it goes throughprojects), so it's a natural follow-up rather than part of this change.
All three are out of scope for this change and need separate fixes.
References
- Issue: #629359 (closed)
- Epic (describes how the N+1 top-offenders list was built): &23614
Screenshots or screen recordings
No UI change; this is a backend query-shape change only. The before/after evidence is the query-count table in the "What does this MR do and why?" section above.
How to set up and validate locally
- Check out this branch and start the GDK as usual.
- Run the new permanent N+1 spec, which asserts the query count doesn't grow as container repositories are added to a page:
To see it catch the regression, stash the app changes (keep the spec) and re-run; it fails on master with something like
bundle exec rspec spec/requests/api/graphql/project/container_repositories_spec.rbExpected 47 queries, but got 50. - Run the model spec for the new scope:
bundle exec rspec spec/models/container_repository_spec.rb - Optional manual check: open a project's Container Registry page with the performance bar enabled and confirm the query count doesn't scale with the number of container repositories listed.
MR acceptance checklist
This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability. Please refer to the acceptance checklist for additional guidance.
- Changelog:
Changelog: performancetrailer is on the commit; no user-facing changelog entry needed. - No database migration.
- No feature flag: this is a pure query-shape change with no behaviour change, so it doesn't need one.
- No user-facing strings changed.
- Database review requested for the new
counts_by_project_idscope and the groupedCOUNTquery it issues (new SQL and index coverage below).
New/changed SQL:
SELECT COUNT(*) AS "count_all", "container_repositories"."project_id" AS "container_repositories_project_id"
FROM "container_repositories"
WHERE "container_repositories"."project_id" IN (278964, 13083)
GROUP BY "container_repositories"."project_id"index_container_repositories_on_project_id_and_id on container_repositories (project_id, id) covers both the filter and the grouping (confirmed by the plan below).
Project IDs are the real production ones documented in doc/development/database_review.md: 278964 (gitlab-org/gitlab) and 13083 (gitlab-org/gitlab-foss).
Before
SELECT COUNT(*) FROM "container_repositories" WHERE "container_repositories"."project_id" = 278964Ran once per container repository node in the page, so a page of N nodes issued N of these.
Plan from Postgres.ai (Database Lab): https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58070/commands/162777
Aggregate (cost=3.89..3.90 rows=1 width=8) (actual time=0.037..0.037 rows=1 loops=1)
Buffers: shared hit=7
I/O Timings: read=0.000 write=0.000
-> Index Only Scan using index_container_repositories_on_project_id_and_id on public.container_repositories (cost=0.43..3.83 rows=23 width=0) (actual time=0.025..0.031 rows=30 loops=1)
Index Cond: (container_repositories. [...truncated in Joe output...]0.922 ms total (0.804 ms planning, 0.118 ms execution), no I/O read, 7 buffer hits, 0 reads.
After
Plan from Postgres.ai (Database Lab): https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58070/commands/162769
GroupAggregate (cost=0.43..6.88 rows=47 width=12) (actual time=4.730..6.774 rows=2 loops=1)
Group Key: container_repositories.project_id
Buffers: shared hit=3 read=9 dirtied=2
WAL: records=2 fpi=2 bytes=16122
I/O Timings: read=6.636 write=0.000
-> Index Only Scan using index_container_repositories_on_project_id_and_id on public.container_repositories (cost=0.43..6.18 rows=47 width=4) (actual time=3.068..6.757 rows=39 loops=1)
Index Cond: (container_repositories.project_id = ANY ('{278964,13083}'::integer[]))
Heap Fetches: 2
Buffers: shared hit=3 read=9 dirtied=2
WAL: records=2 fpi=2 bytes=16122
I/O Timings: read=6.636 write=0.000
Settings: effective_cache_size = '472585MB', work_mem = '230MB', jit = 'off', random_page_cost = '1.5', seq_page_cost = '4'
Query ID: 32392324344690493797.818 ms total (0.978 ms planning, 6.840 ms execution), of which 6.636 ms of execution is I/O read. Buffers: 3 hits, 9 reads (~96 KiB), 2 dirtied.
- The covering index prediction held:
index_container_repositories_on_project_id_and_idis used as an Index Only Scan. No sequential scan, and no separate sort step —GroupAggregateconsumes the index order directly, soGROUP BY project_idcosts nothing extra. Heap Fetches: 2out of 39 index rows scanned, so almost everything is served from the index alone; the visibility map is doing its job.- Nearly all of the 6.84 ms execution time is cold-cache I/O (6.636 ms read). On a warm buffer pool this is a fraction of a millisecond of actual CPU work.
WAL: records=2 fpi=2is hint-bit / visibility-map maintenance on first touch of cold pages, not per-execution write cost. - Planner estimate of 47 rows against 39 actual is close enough; no misestimation risk here.
- Comfortably inside GitLab's query timing guidelines.
- Scaling: the
INlist holds one entry per distinct project in the GraphQL batch. For theFetchProjectContainerRepositoriesoperation that's a single project, so the list has one element; the two-element list above is the worst case only in the sense that cost grows linearly with distinct project IDs, bounded by the GraphQL page size.
Before vs after
The raw totals (0.922 ms before vs 7.818 ms after) are not a like-for-like comparison and should not be read as the new query being slower.
The before plan ran fully warm: 7 buffer hits, 0 reads, 0.000 ms I/O. The after plan ran cold: 9 reads, and 6.636 ms of its 6.840 ms execution was I/O read on the Database Lab clone.
Like-for-like on CPU, subtracting I/O: before is 0.118 ms of execution, after is 6.840 − 6.636 = 0.204 ms. Same order of magnitude.
The after query also does strictly more work per call: it counts two projects (39 index rows) where the before query counts one (30 index rows).
Both use the same access path, an Index Only Scan on index_container_repositories_on_project_id_and_id, so nothing about the index changed; only the number of calls did.
The actual win is call count, not per-call cost. The before query ran once per container repository node, so a page of N nodes issued N of these, each with its own ~0.8 ms planning and ~7 buffer hits, against exactly one call after the change regardless of page size.
For a 20-node page, that's roughly 20 calls and ~140 buffer hits before, versus 1 call and 12 buffers after. This is an illustration from these two plans' numbers, not a measured production figure.
This is what takes the per-page db_main_replica_count contribution of this field from N down to 1.
Test runs (all local, all passing):
spec/requests/api/graphql/project/container_repositories_spec.rb,spec/requests/api/graphql/group/container_repositories_spec.rb,spec/models/container_repository_spec.rb: 161 examples, 0 failuresspec/graphql/types/project_type_spec.rb,spec/graphql/resolvers/concerns/resolves_groups_spec.rb: passingspec/graphql/resolvers/projects_resolver_spec.rb,spec/requests/api/graphql/projects/projects_spec.rb,spec/requests/api/graphql/namespace/projects_spec.rb: 63 examples, 0 failures- RuboCop clean on all five changed files.