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::Provideras 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_typereturns the custom lifecycle when one is attached, otherwise the system-defined one; lookups key bypersistable_idso converted custom types resolve identically. - Extracts the shared type interface (
status_lifecycle_for,custom_lifecycle_for,custom_status_enabled_for?) into aWorkItems::Statuses::Lifecycles::HasLifecyclemodule included by bothSystemDefined::TypeandCustom::Type, so the resolution lives in one place instead of being duplicated per class. - Routes
has_status#find_lifecyclethrough the provider and removes its ad-hoc request-store cache (the provider owns caching now) and the dead no-namespace branch. Drops the privatefind_custom_lifecycle_forquery fromCustom::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
:epicin 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_typesis 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: -5687557809269193884Verification
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.