Give approval gates a step reference
What does this MR do and why?
Cd::RolloutTransition rows that open an approval gate previously carried no reference to the Cd::RolloutStep they belong to. The frontend (flow_footer.vue, added in !250245 (merged)) worked around this by matching the gated step through the current rollout_steps state, which only holds up because, today, gates always open for approval steps and only one gate is ever open at a time.
Per the CD Rails design doc (https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/gitlab_cd/rails/), gates are intentionally generic and are expected to open for non-step reasons later (a pause requiring manual resume, a policy-driven freeze), so that correlation needs to be explicit rather than inferred.
This MR:
- Adds a nullable
rollout_step_idreference tocd_rollout_transitions. - Populates it in
Cd::Rollouts::WorkflowEvents::RolloutTransition#open_gate, which already resolves the triggering step for#transition_step. - Adds
Cd::RolloutGate, a read-side derivation that pairs eachrequest_approvaltransition in a rollout's journal with whichever approve/reject transition closed it (or nothing, if still open) — covering every gate in the journal, not just the latest.Cd::RolloutGate.for_rolloutsbuilds this for a batch of rollouts in one query (plus one preload query each for steps and rollouts), so resolvinggatesacross a list of rollouts doesn't issue an N+1. - Exposes this as
CdRollout.gatesover GraphQL (CdRolloutGate: state, name, reason, resolvedAt, resolvedBy, step), backed by a dedicatedCd::RolloutGatePolicy, and batched viaBatchLoader::GraphQLthe same wayawaitingApproval/triggeredByUseralready are onCdRollout.
This is a follow-up to !250186 (merged) and does not change CdRollout.awaitingApproval or CdRollout.rolloutTransitions, which remain available.
Addressed in review (GitLab Duo):
- Added a migration spec for
AddFkCdRolloutTransitionsToCdRolloutSteps, asserting the FK'son_delete: :nullifybehavior explicitly. - Fixed a memoization bug in
Cd::Rollouts::WorkflowEvents::RolloutTransition#step:@step ||=didn't cache anilresult, so an unmatched step lookup re-ran the query from both#open_gateand#transition_stepinstead of sharing one lookup as the comment claimed. - Batched
Cd::RolloutGate.for_rollout/RolloutGatesResolveras above —gatessits onCdRollout, which is also the connection type forCdApplication.rolloutsandCdVersionSet.rollouts, so this was an active N+1 (confirmed with a failing regression test before the fix), not just a hypothetical future one.
Screenshots or screen recordings
N/A — backend/GraphQL only, no UI change in this MR.
How to set up and validate locally
query {
organization(id: "gid://gitlab/Organizations::Organization/<id>") {
cdRollout(id: "gid://gitlab/Cd::Rollout/<id>") {
gates {
id
state
name
reason
resolvedAt
resolvedBy { username }
step { id name }
}
}
}
}Database review
Two regular migrations, both against cd_rollout_transitions (small table, per db/docs/cd_rollout_transitions.yml):
AddRolloutStepIdToCdRolloutTransitions—add_column :cd_rollout_transitions, :rollout_step_id, :bigint + add_concurrent_index.AddFkCdRolloutTransitionsToCdRolloutSteps—add_concurrent_foreign_key :cd_rollout_transitions, :cd_rollout_steps, column: :rollout_step_id, on_delete: :nullify.
on_delete: :nullify (not the usual :cascade) is deliberate: cd_rollout_transitions is an append-only audit journal, so a hypothetical step deletion shouldn't delete its journal entry. Covered by spec/migrations/20260820100001_add_fk_cd_rollout_transitions_to_cd_rollout_steps_spec.rb.
Query pattern (Cd::RolloutGate.for_rollouts, batched across every rollout id the resolver is asked for in one GraphQL request):
Cd::RolloutTransition
.gate_events
.where(rollout_id: rollout_ids)
.order(:rollout_id, created_at: :asc, id: :asc)
.includes(:rollout_step, :rollout)(Plans generated locally against a near-empty cd_rollout_transitions table with enable_seqscan = off to force the planner to reveal index selection at scale, since this table currently has negligible production volume — it's a recently introduced Beta feature.)
Query 1
Journal read, for every rollout id being resolved in the current request
SELECT "cd_rollout_transitions".*
FROM "cd_rollout_transitions"
WHERE "cd_rollout_transitions"."event" IN ('request_approval', 'approve', 'reject')
AND "cd_rollout_transitions"."rollout_id" IN (1, 2)
ORDER BY "cd_rollout_transitions"."rollout_id" ASC, "cd_rollout_transitions"."created_at" ASC, "cd_rollout_transitions"."id" ASC
Incremental Sort (cost=3.54..3.58 rows=2 width=181)
Sort Key: rollout_id, created_at, id
Presorted Key: rollout_id, created_at
-> Index Scan using index_cd_rollout_transitions_on_rollout_id_and_created_at on cd_rollout_transitions (cost=0.25..3.53 rows=1 width=181)
Index Cond: (rollout_id = ANY ('{1,2}'::bigint[]))
Filter: (event = ANY ('{request_approval,approve,reject}'::text[]))Reuses the existing (rollout_id, created_at) composite index already reviewed for this table in !250186 (merged), now with rollout_id = ANY (...) for the batched form. The index already gives (rollout_id, created_at) order; the id tiebreaker needs a small Incremental Sort on top rather than a full sort. event IN (...) is a post-scan filter, acceptable since the index already narrows to the given rollouts' bounded journals.
Query 2
rollout_step preload, one query regardless of how many gates are returned
SELECT "cd_rollout_steps".*
FROM "cd_rollout_steps"
WHERE "cd_rollout_steps"."id" IN (1, 2, 3)
Index Scan using cd_rollout_steps_pkey on cd_rollout_steps (cost=0.15..4.20 rows=3 width=258)
Index Cond: (id = ANY ('{1,2,3}'::bigint[]))Query 3
rollout preload — Cd::RolloutGatePolicy reads #rollout on every gate to authorize it; preloaded up front so authorization doesn't issue one query per distinct rollout.
SELECT "cd_rollouts".*
FROM "cd_rollouts"
WHERE "cd_rollouts"."id" IN (1, 2)
Index Scan using cd_rollouts_pkey on cd_rollouts (cost=0.27..4.54 rows=2 width=102)
Index Cond: (id = ANY ('{1,2}'::bigint[]))MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Related to #616421