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_id reference to cd_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 each request_approval transition 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_rollouts builds this for a batch of rollouts in one query (plus one preload query each for steps and rollouts), so resolving gates across a list of rollouts doesn't issue an N+1.
  • Exposes this as CdRollout.gates over GraphQL (CdRolloutGate: state, name, reason, resolvedAt, resolvedBy, step), backed by a dedicated Cd::RolloutGatePolicy, and batched via BatchLoader::GraphQL the same way awaitingApproval/triggeredByUser already are on CdRollout.

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's on_delete: :nullify behavior explicitly.
  • Fixed a memoization bug in Cd::Rollouts::WorkflowEvents::RolloutTransition#step: @step ||= didn't cache a nil result, so an unmatched step lookup re-ran the query from both #open_gate and #transition_step instead of sharing one lookup as the comment claimed.
  • Batched Cd::RolloutGate.for_rollout/RolloutGatesResolver as above — gates sits on CdRollout, which is also the connection type for CdApplication.rollouts and CdVersionSet.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

Edited by Carla Drago

Merge request reports

Loading
Loading