Avoid re-fetching issues by id for board list counts
What does this MR do and why?
Board list counts and total weight (BoardList.issuesCount and totalIssueWeight in GraphQL) now aggregate the board finder query directly instead of wrapping it in issues.id IN (...). The wrapper made Postgres look up every matching issue a second time by primary key, on top of the work the finder query already did to find them.
This is a hot path: BoardListCountEE is about 17% of the planning_views error budget spend for the week of 2026-09-20 to 09-27, and its slow requests often reach the 15s statement timeout, with the database accounting for most of the time.
Epic boards are unchanged. They keep the id IN query and still count with COUNT(DISTINCT epics.id).
Database review
Before:
SELECT COUNT(*), COALESCE(SUM(weight), 0) FROM issues WHERE issues.id IN (<board finder query>)After:
SELECT COUNT(*), COALESCE(SUM(issues.weight), 0) FROM issues WHERE <board finder conditions>weight is now qualified as issues.weight because group boards join projects.
Database Lab, real production project board open list with 770 matching issues, warm cache, same plan apart from the removed lookup:
More Database Lab detail
On a cold cache, the extra lookups (an Index Only Scan on index_issues_on_id_and_weight, once per matching issue) accounted for about 0.9s of 2.1s of I/O, with 1,137 extra page reads. Count only and count plus weight both used 3,242 buffers, so the weight sum isn't the cost, the id IN wrapper is.
A group board label list with 2 matching issues barely moved: 8,140 buffers before, 7,952 after. Its cost is the scan on idx_issues_root_namespace_created_at for the root namespace, which this MR doesn't touch and is a follow-up. Group boards are roughly two thirds of the slow count requests, so the expected effect of this MR alone is about 0.4 to 0.5 minutes of error budget recovered per week, from project board count requests dropping under the 5s threshold.
Why this doesn't change the counts
The id IN wrapper also collapsed duplicate rows, so I checked every filter a board can apply. They add EXISTS / NOT EXISTS conditions, column comparisons, or joins with at most one row per issue: milestones and sprints are belongs_to, epic_issues is unique on issue_id, work_item_current_statuses is unique on work_item_id, projects is unique on project_namespace_id, project_features is unique on project_id, and issue_search_data has primary key (project_id, issue_id) and is matched on project_id.
The label filter CTE is never used for boards, because it is skipped when a parent is set. The search CTE (project boards, non-Latin search terms) keeps the table name issues, so issues.weight still resolves. Two finder paths that could duplicate rows aren't reachable from boards: the subscribed filter (not a board argument) and status sorting (boards don't sort through the finder).
Edge case: when Rails can tell a query can't match anything (for example an anonymous user on a private project, which produces an empty IN list), pluck returns no row without running the query. The old wrapper hid this because the empty condition sat inside the subquery. Aggregates always return a row when they run, so metadata now treats no row as zeros, matching the previous behaviour.
References
None. This came out of investigating the planning_views error budget drop.
Screenshots or screen recordings
Backend-only change, no UI change.
How to set up and validate locally
- Open a project issue board with a few issues in the backlog and a label list.
- Check the list counts and total weight in the list headers are the same before and after this change.
- In a Rails console, compare
Boards::Issues::ListService.new(project, user, board_id: board.id, id: list.id).metadatawithBoards::Issues::ListService.new(project, user, board_id: board.id, id: list.id).execute.to_a.size. - Optionally, tail
log/development.logto confirm the count query no longer contains anissues.id IN (subquery.
Test coverage added
- An EE table spec compares
metadatawith#executefor backlog, label, assignee, milestone, iteration and closed lists across board filters: labels (plain, NOT, OR), milestone, iteration, epic, weight, search (including a non-Latin term), confidential, author, author OR and assignee. - A CE shared example compares counts for every list with a signed-in and an anonymous user on project, archived project and group boards, plus Latin and non-Latin searches on project boards, which cover the full-text and CTE search paths.
- A query-shape spec asserts the count query no longer contains
"issues"."id" IN (. - Mutation check: temporarily adding a join that duplicates rows made 21 of the 32 comparison examples fail, so the specs catch duplication.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.