Loading
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.
- This replaces the earlier version of this MR, which removed 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 is identical on every page of the same query.
- 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.
History of this MR
- The previous version skipped the count behind the flag
skip_total_count_on_group_merge_requests_apiand removedX-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).
- That flag is removed in this version.
- A breaking change exception request was filed for the skip version: gitlab-com/Product#14583.
- A variant that built the headers without a Kaminari patch was tried in !261177 (closed). It was closed in favour of this one because it had to reimplement Kaminari's next and prev page math.
- After review, page 1 was made to always count: !257173 (comment 3995493014)
What changes
- Flag:
cache_total_count_on_group_merge_requests_api, typegitlab_com_derisk, default off. The actor is the requested group, not the root ancestor. Rollout issue: #630469. - With the flag on, all headers are 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 endpoints are unchanged. With the flag off the group endpoint is unchanged.
Implementation
config/initializers/kaminari_active_record_relation_methods_with_limit.rb: newpreset_total_count(count)sets Kaminari's memoised@total_count.total_count_with_limitalready returns early when@total_countis set, soX-Total,X-Total-Pages,next_pageandprev_pageall use the preset value and no COUNT runs.lib/gitlab/pagination/offset_pagination.rb: new keywordcount_cache_key:onpaginate, with the constantCOUNT_CACHE_EXPIRES_IN = 15.minutes.apply_cached_countreadsRails.cache. Page 1 always runstotal_count_with_limitand writes the result to the cache. On later pages, if there is a cached count and the current page is before the last page that count implies (1 < current_page < ceil(count / per_page)), it presets it. Otherwise it counts and writes. It is skipped whenwithout_countis true.- Cache key:
[:offset_pagination_total_count, [:group_merge_requests, group id, user id], SHA256 of relation.except(:offset, :limit, :order).to_sql].- The count SQL covers every filter and admin mode, and ignores serialization-only params (like
with_labels_details) and sort order. - The user id keeps counts per user even if visibility filtering ever moves out of the SQL.
- An earlier cache iteration keyed on a hash of the request params. That missed admin mode and split the cache on serialization-only params.
- The count SQL covers every filter and admin mode, and ignores serialization-only params (like
lib/api/merge_requests.rb:find_merge_requests(count_cache_key: nil, **args). The group route passesgroup_merge_requests_count_cache_key, which returns nil when the flag is off. The signature uses**argsbecause(args = {}, keyword: ...)makesfind_merge_requests(project_id: ...)raiseArgumentErroron Ruby 3.- Last-page recount: the page the cached count calls the last one (or any later page) always recounts and rewrites the cache. A stale low count can never drop the next link, so clients following next links never miss MRs.
- Over 10,000 results: the totals are still omitted. A cached count above the limit is reused on every page after the first, also past the page it calls the last one, because the
without_countpath finds the next page from the loaded records and never shows the count.
Trade-offs and staleness
- Page 1 is always exact:
X-Total,X-Total-Pagesand theLinklast link are the same as today. - On pages after the first and before the last,
X-TotalandX-Total-Pagesshow the count from the latest page 1 or last page of the same query by the same user, at most 15 minutes old. - If more than
per_pageMRs close or merge during the TTL, a walking client can hit some empty pages before the cached last page, which recounts and ends the walk. - A client that reads
X-Total-Pageson page 1 and fetches the rest in parallel is not affected, because page 1 is exact. - Permission changes inside the TTL: if a user loses access to a project, their
X-Totalcan still include its MRs for up to 15 minutes. Only the number is affected, never the MRs. - Results that fit on one page always recount. Those counts are cheap.
- Precedent:
Groups::MergeRequestsCountServiceserves the group open MR count from a 24 hour cache once it is over 1,000, shared across users.
Open question for API owners
- Is an
X-Totalup to 15 minutes old on pages after the first and before the last acceptable as "no change" for the API contract?
Not in scope
- An opt-in
without_count=truerequest param (approved in !257173 (comment 3961105985)). This is a separate change. - Per merge request mergeability check costs.
- The per-MR policy rule query, which was split into !257613 (merged) (merged).
Verification done
spec/lib/gitlab/pagination/offset_pagination_spec.rb: 41 examples, 0 failures. New examples check:- counts on the first request and caches the result
- counts again on the first page even with a cached count
- reuses the cached count on later pages before the last page
- reuses a cached count over the limit on the page it calls the last one
- recounts on the page the cached count calls the last one
- counts again for another query with the same cache key (this one fails if the SQL is left out of the key)
- ignores the cache when
without_countis true
spec/requests/api/merge_requests_spec.rb, context 'total count cache':- reuses the count from an earlier request on page 2
- reuses it when only serialization params change (
with_labels_details) - counts again on the first page
- counts again on page 2 for another user or other filters
- with the flag off every request counts
- Group list block: 139 examples, 0 failures. Without the page 1 change, the two first-page examples fail.
- RuboCop clean.
How to set up and validate locally
- In a Rails console on GDK, enable the flag for a group with a few merge requests:
Feature.enable(:cache_total_count_on_group_merge_requests_api, Group.find_by_full_path('<group>')). - Request page 1, then page 2, and compare
X-Total:curl -sI --header "PRIVATE-TOKEN: <token>" "http://gdk.test:3000/api/v4/groups/<group_id>/merge_requests?per_page=1"(add&page=2). - Run
tail -f log/development.logwhile doing this. You should seeSELECT COUNT(*) FROM (SELECT 1 AS one FROM "merge_requests"only on page 1 and on the last page. - Add
with_labels_details=trueand check that no new count runs. - 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
- This MR before the rewrite (skip version) and API owner comments: !257173 (comment 3961105985) and !257173 (comment 3983043278)
- Breaking change exception request: gitlab-com/Product#14583
- Rollout issue: #630469
- Closed sibling without a Kaminari patch: !261177 (closed)
- Policy rule query split: !257613 (merged)
Edited by Marc Shaw