Extract status lifecycle resolution into a provider

What does this MR do and why?

This is the first, pure-refactor step toward status on epics (and any future work item type). It extracts the duplicated status-lifecycle resolution into a single collaborator, WorkItems::Statuses::Lifecycles::Provider, and routes all callers through it. There is no behavior change: every existing status read resolves exactly as it does today.

Related to gitlab-org#17709

BE: Lifecycle provider + centralization (status... (#606011 - closed)

The problem

Status resolves through a lifecycle per (root namespace, work item type), either the in-code system-defined lifecycle or a DB-backed custom one. The custom_lifecycle_for(namespace) || system_defined_lifecycle fallback is duplicated across several call sites: both type classes (SystemDefined::Type, Custom::Type) and has_status's find_lifecycle, the last with its own ad-hoc request-store cache. That duplication is the seam every follow-up (the mask, the feature-flag gate, persist-on-write) would otherwise have to touch in several places.

What this MR does

  • Adds WorkItems::Statuses::Lifecycles::Provider as the single source of truth for lifecycle resolution. Given a root namespace id, it loads the custom lifecycles for that namespace once (a single query), indexes them by type, and caches the index in the request store. find_by_type returns the custom lifecycle when one is attached, otherwise the system-defined one; lookups key by persistable_id so converted custom types resolve identically.
  • Extracts the shared type interface (status_lifecycle_for, custom_lifecycle_for, custom_status_enabled_for?) into a WorkItems::Statuses::Lifecycles::HasLifecycle module included by both SystemDefined::Type and Custom::Type, so the resolution lives in one place instead of being duplicated per class.
  • Routes has_status#find_lifecycle through the provider and removes its ad-hoc request-store cache (the provider owns caching now) and the dead no-namespace branch. Drops the private find_custom_lifecycle_for query from Custom::Type.

Callers pass a root namespace id. Every existing caller already resolves the root namespace before calling, so the object-passing call sites now pass .id (free, they already hold the object) and the provider does no namespace lookup of its own.

What this MR deliberately does NOT do

  • No masking / cross-type fallback, no feature flag, no persist-on-write, no :epic in the system-defined lifecycle, no status widget on any type. Those land in follow-up MRs. This is the behavior-preserving extraction only.
  • Custom::Lifecycle#work_item_types is unchanged.

Queries and plans

This is the load bearing query that builds the index. Pretty much the same we used before but this time not scoped to namespace and type but only to namespace.

-- (1) primary: all type<->lifecycle rows for the namespace
SELECT "work_item_type_custom_lifecycles".*
FROM "work_item_type_custom_lifecycles"
WHERE "work_item_type_custom_lifecycles"."namespace_id" = 9970;

-- (2) preload lifecycles (ids from step 1)
SELECT "work_item_custom_lifecycles".*
FROM "work_item_custom_lifecycles"
WHERE "work_item_custom_lifecycles"."id" IN (4, 3);

-- (3) preload lifecycle_statuses join rows (lifecycle ids from step 2)
SELECT "work_item_custom_lifecycle_statuses".*
FROM "work_item_custom_lifecycle_statuses"
WHERE "work_item_custom_lifecycle_statuses"."lifecycle_id" IN (3, 4);

-- (4) preload the statuses themselves (ids from step 3 + the three default_* fks, batched)
SELECT "work_item_custom_statuses".*
FROM "work_item_custom_statuses"
WHERE "work_item_custom_statuses"."id" IN (16, 21, 17, 18, 19, 20);

Query plan for the first query:

https://postgres.ai/console/gitlab/gitlab-production-main/sessions/53799/commands/156119

Index Scan using idx_wi_type_custom_lifecycles_on_namespace_and_work_item_type on public.work_item_type_custom_lifecycles  (cost=0.28..4.70 rows=2 width=48) (actual time=0.470..0.839 rows=2 loops=1)
   Index Cond: (work_item_type_custom_lifecycles.namespace_id = 9970)
   Buffers: shared hit=5 read=2
   I/O Timings: read=0.801 write=0.000
Settings: seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5', work_mem = '230MB'
Query ID: -5687557809269193884

Verification

The proof of no behavior change is that the pre-existing status specs pass unchanged (system_defined/type_spec, custom/type_spec, current_status_spec, work_item_spec, the widgets/statuses and work-items update service specs). A focused provider spec is added for the resolution and request-store caching behavior. The group and project work-items GraphQL request specs (including the status-widget N+1 guards) stay green, confirming the per-request index cache keeps the list read free of N+1s.

Edited by Marc Saleiko

Merge request reports

Loading
Loading