Skip the legacy and missing-row status branches once rows are backfilled

What does this MR do and why?

The problem

Filtering work items by status is slow for big groups, and a status board in gitlab-org times out (https://gitlab.com/gitlab-org/gitlab/-/work_items/602041).

The reason is how status is stored. A work item's status can live in three places:

  1. A row in work_item_current_statuses with custom_status_id set. This column is indexed.
  2. A row with the older system_defined_status_id set. No index can serve the filter from this column.
  3. No row at all. The status is derived from the item's state at read time.

The filter has to look in all three places, so it is one query with three OR branches. Branches 2 and 3 cannot use an index, so Postgres scans every open item in the group and checks each one. For gitlab-org that is hundreds of thousands of rows for a single column.

What already happened in 19.5

Two batched background migrations and a conversion worker (gitlab-org#23612 (closed)) moved every issue and task in a converted group to place 1: one row, custom_status_id set. Both migrations have finished on GitLab.com. Their finalize migrations (!263043 (merged) for the missing-rows backfill, !262655 (merged) for the legacy-column backfill, both merged) and the 19.5 required stop guarantee that this holds on every instance before the code below can run.

What this MR does

When the work_item_status_single_column_filter flag is on for a root namespace, the filter drops the two branches that cannot use an index:

  • Branch 2, the legacy column, is dropped for every type.
  • Branch 3, the missing row, is dropped per type, for the types listed in RowBackfill::BACKFILLED_BASE_TYPES. That is issues and tasks today. Epics join once their own backfill (#632560) is finalized, tickets later.

If a branch were dropped while rows were still missing, those items would vanish from the filter and from their board column. The flag paces the GitLab.com rollout, and for other instances the 19.5 required stop plus the finalize migrations guarantee the rows exist before the flag is removed.

What this MR does not do on its own

The board also counts every column, and a count has to visit every matching row. With 65,000 items in a column that still takes over a minute after this MR. !262518 (merged) caps board counts at 1,000. The two MRs ship together: this one makes the first page fast, the cap makes the count fast. The plans below show each alone and both together.

Rollout and cleanup

gitlab-org and gitlab-com first, everyone after #633143 (closed) makes rows universal. Steps in #634015. The flag is removed in #629284 (19.7).

References

How to set up and validate locally

bin/rspec ee/spec/models/work_items/statuses/row_backfill_spec.rb ee/spec/models/work_item_spec.rb -e ".with_status"

Query plans

gitlab-org, status "New", open issues and tasks. New is the biggest column, about 65,000 items. Database Lab clone from 19 October 2026, after both backfills finished. Every run is cold cache on the same clone. Before is today's three-branch query, after is this MR with the flag on.

Query Before After
First page of the column 8,035 buffers, 6.3 s (plan) 368 buffers, 76 ms (plan)
Column count, no cap 1,661,454 buffers, 7.3 min (plan) 696,780 buffers, 1.7 min (plan)
Column count, capped at 1,000 (!262518 (merged)) 31,214 buffers, 22.8 s (plan) 8,099 buffers, 1.5 s (plan)

This MR fixes the first page. The cap fixes the count. Only both together bring the count to 1.5 s: this MR alone leaves it at 1.7 min, the cap alone at 22.8 s.

SQL

Before:

SELECT "issues"."id"
FROM "issues"
LEFT OUTER JOIN "work_item_current_statuses" ON "work_item_current_statuses"."work_item_id" = "issues"."id"
WHERE "issues"."namespace_traversal_ids"[1] = 9970
  AND "issues"."state_id" = 1
  AND "issues"."work_item_type_id" IN (1, 5)
  AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL AND "work_item_current_statuses"."custom_status_id" = 30
    OR "issues"."state_id" = 1 AND "issues"."work_item_type_id" = 1 AND "work_item_current_statuses"."id" IS NULL
    OR "work_item_current_statuses"."system_defined_status_id" IS NOT NULL AND "work_item_current_statuses"."system_defined_status_id" = 1
    OR "issues"."state_id" = 1 AND "work_item_current_statuses"."id" IS NULL AND "issues"."work_item_type_id" IN (1, 5, 8))
ORDER BY "issues"."id" DESC
LIMIT 20;

After:

SELECT "issues"."id"
FROM "issues"
LEFT OUTER JOIN "work_item_current_statuses" ON "work_item_current_statuses"."work_item_id" = "issues"."id"
WHERE "issues"."namespace_traversal_ids"[1] = 9970
  AND "issues"."state_id" = 1
  AND "issues"."work_item_type_id" IN (1, 5)
  AND "work_item_current_statuses"."custom_status_id" IS NOT NULL
  AND "work_item_current_statuses"."custom_status_id" = 30
ORDER BY "issues"."id" DESC
LIMIT 20;

Counts: the same two WHERE clauses with SELECT count(*) and no ORDER BY / LIMIT. Capped counts wrap them as SELECT count(*) FROM (SELECT 1 ... LIMIT 1001) capped; the cap is 1,000 and the extra row is how the board knows to show "1,000+".

Edited by Vedant Jain

Merge request reports

Loading
Loading