Loading
Draft: Cache the total count on the group merge requests API
Behind a new gitlab_com_derisk flag cache_total_count_on_group_merge_requests_api (default off, group actor), GET /groups/:id/merge_requests caches the count behind X-Total for 15 minutes instead of running it on every page.
- All pagination headers stay. A client walking every page pays for a count on the first and last page only.
- Alternative to !257173, which removes the headers and was ruled a breaking change.
Detailed context for AI agents
Problem
GET /groups/:id/merge_requestsruns an exactCOUNT(Kaminaritotal_count_with_limit, capped atLIMIT 10001) on every request to fillX-TotalandX-Total-Pages.- The count strips offset, limit and order, so every page of the same query runs the identical count.
- On gitlab-org it took 1240ms of 2400ms DB time in one request.
- Heavy callers walk every page: 5,400 open MRs at
per_page=100is 54 identical counts per walk.
Why not the earlier approach
- !257173 skips the count and removes
X-TotalandX-Total-Pages. - API owners said removing them by default is a breaking change that needs VP approval (!257173 (comment 3961105985) and !257173 (comment 3983043278)).
- This MR keeps the headers and caches the count instead.
- The earlier cache version of that MR used a
preset_total_countpatch to Kaminari. This one needs no Kaminari patch.
What changes
- Flag:
cache_total_count_on_group_merge_requests_api, typegitlab_com_derisk, default off, actor is the requested group. - With the flag on, these headers are all still returned:
X-Total,X-Total-Pages,X-Page,X-Per-Page,X-Next-Page,X-Prev-Pageand theLinkheader (first/prev/next/last). - Project and global (
/merge_requests) endpoints are unchanged. With the flag off the group endpoint is unchanged.
Implementation
lib/gitlab/pagination/offset_pagination.rb: new opt-in keywordcache_total_count:onpaginate. When true (andwithout_countis false and the input is anActiveRecord::Relation), it reads the count fromRails.cache, or runstotal_count_with_limitand writes it with a 15 minute TTL (constantTOTAL_COUNT_CACHE_TTL).- Cache key: SHA256 of
relation.except(:offset, :limit, :order).to_sql. The SQL already contains the user id (theproject_authorizationssubquery), the effect of admin mode, and every filter, so the same SQL means the same rows. Different users, filters, states or groups get different keys. Sort order does not change the key, which is correct because it does not change the count. - Headers are built directly with
Gitlab::Pagination::OffsetHeaderBuilderfrom the cached total.next_pageandprev_pageare computed the same way Kaminari does (next only when current page < total pages, prev only when current page > 1 and not out of range). This avoids Kaminari'snext_pageandprev_page, which would run the count again. - Last-page recount: if the request is on or past the last page according to the cached count, it recounts and rewrites the cache. A stale count that is too low can never drop the next link before the real last page, so clients never miss MRs.
- Staleness: before the last page,
X-TotalandX-Total-Pagescan be up to 15 minutes old. If many MRs were closed or merged during the TTL (more thanper_page), a walking client can get some empty pages at the end before it reaches the cached last page, which recounts and ends the walk. - The recount only protects clients that follow the next link. A client that reads
X-Total-Pagesfrom page 1 and fetches the remaining pages in parallel can miss trailing pages, or fetch an extra empty one, for up to 15 minutes. - Results that fit on one page are always on the last page, so they recount every time. Those counts are cheap.
- Over 10,000 results: same as today. The totals are omitted and the next page comes from Kaminari's
without_countlookahead. - Why not use the
without_countlookahead for the next link in the normal case:find_merge_requestschains.preload(...)and.with_api_entity_associationsafterpaginate, which creates a fresh unloaded relation. The lookahead's loaded page would be thrown away and the page query would run twice. - The existing pagination branch in
paginate_with_limit_optimizationwas only refactored to share a smallapply_paginationhelper. Behaviour with the flag off is unchanged. lib/api/merge_requests.rb:find_merge_requests(cache_total_count: false, **args)passes the option topaginate. The group route passesFeature.enabled?(:cache_total_count_on_group_merge_requests_api, user_group). The signature uses**argsbecause a(args = {}, keyword: ...)signature makesfind_merge_requests(project_id: ...)raiseArgumentErroron Ruby 3.- Precedent for serving a cached group MR count:
Groups::MergeRequestsCountServicealready serves the group open MR count (sidebar) from a 24 hour cache once it is over 1,000.
Open question for API owners
- Does an
X-Totalthat can be up to 15 minutes old on non-final pages count as "no change" for the API contract?
Rollout
- Rollout issue not created yet (
rollout_issue_urlin the flag YAML is empty).
Not in scope
- An opt-in
without_count=truerequest parameter (approved in !257173 (comment 3961105985)). This is a separate change. - Per merge request mergeability check costs inside
detailed_merge_status.
Verification done
spec/lib/gitlab/pagination/offset_pagination_spec.rb: 47 examples, 0 failures. New examples check:- cached and uncached requests produce identical headers on pages 1, 2 and 3 (out of range), with and without a warm cache
- one COUNT, then reuse
- the key changes with the query
- the count expires after the TTL
- a stale low count is served before the last page and recounted on the last page, so the next link is kept
- over
MAX_COUNT_LIMITthe totals are omitted without_countstill skips the count- with the cache branch disabled, the two cache-specific examples fail
spec/requests/api/merge_requests_spec.rb: new group endpoint context. The second request runs nomerge_requestsCOUNT and returns the sameX-Total,X-Total-PagesandX-Next-Page. A member and a non-member of a private project in the group get differentX-Totalvalues, so the count is not shared between users. With the flag disabled every request counts. Group endpoint block: 137 examples, 0 failures. Global and project list blocks: 162 examples, 0 failures.- RuboCop clean on changed files.
How to set up and validate locally
- In a Rails console on GDK, enable the flag for a group:
Feature.enable(:cache_total_count_on_group_merge_requests_api, Group.find_by_full_path('<group>')). Make sure the group has a few merge requests. - Call the endpoint twice and compare
X-Total:curl -sI --header "PRIVATE-TOKEN: <token>" "http://gdk.test:3000/api/v4/groups/<group_id>/merge_requests?per_page=1". - Run
tail -f log/development.logwhile doing this. You should see theSELECT COUNT(*) FROM (SELECT 1 AS one FROM "merge_requests" ...query only on the first request (and again on the last page). - Walk the pages with
page=1,page=2and so on up to the last page. Check thatX-Next-Pageand theLinkheader are correct on every page and thatX-Next-Pageis empty on the last page. - Disable the flag with
Feature.disable(:cache_total_count_on_group_merge_requests_api, Group.find_by_full_path('<group>'))and repeat. Every request should now run the count.
References
- Earlier approach (skips the count): !257173
- API owners on removing the headers: !257173 (comment 3961105985) and !257173 (comment 3983043278)