Update plan_limits to use new plan associations and enum identifiers
Summary
This task migrates plan_limits plan filtering from plan_id (database foreign key to plans.id) to plan_name_uid (in-memory enum identifier), removing the dependency on the plans table.
Phase 1: Make plan_name_uid the Source of Truth for Writes
The plan_name_uid column already exists on plan_limits (added in migration 20251119195901), is indexed (index_plan_limits_on_plan_name_uid, added in 20251124211745), and is being backfilled (BackfillPlanLimitsPlanNameUid, queued in 20260115114840). The set_plan_name_uid callback in app/models/plan_limits.rb already dual-writes both plan_id and plan_name_uid.
-
Confirm the backfill migration
BackfillPlanLimitsPlanNameUidhas completed on production -
Update
set_plan_name_uidinapp/models/plan_limits.rb: Makeplan_name_uidthe primary write. Continue writingplan_idfor backward compatibility, but derive the uid from the in-memoryPlan::PLAN_NAME_UID_LISTinstead of callingplan&.plan_name_uid_before_type_cast(which loads the Plan record via the FK). Currently:def set_plan_name_uid return if plan_name_uid.present? && !plan_id_changed? self.plan_name_uid = plan&.plan_name_uid_before_type_cast endUpdate to derive
plan_name_uidfrom the plan name usingPlan::PLAN_NAME_UID_LISTdirectly, and keepplan_idpopulated for backward compatibility. -
Update
create_or_update_plan_limitmigration helper inlib/gitlab/database/migration_helpers.rb: Currently usesINSERT INTO plan_limits (plan_id, ...) SELECT id, ... FROM plans WHERE name = ... ON CONFLICT (plan_id). Update to also writeplan_name_uidusing the in-memory enum, and prepare for the eventual switch toON CONFLICT (plan_name_uid). Keepplan_idpopulated for backward compatibility. -
Update
Admin::PlanLimitsController(app/controllers/admin/plan_limits_controller.rb): Currently doesPlan.find(plan_limits_params[:plan_id]).actual_limits. Updateset_plan_limitsto also acceptplan_name_uidas the primary identifier, keepingplan_idsupported for backward compatibility. -
Update admin views (
app/views/admin/application_settings/_ci_cd.html.hamlandapp/views/admin/application_settings/_package_registry.html.haml): Currently usef.hidden_field(:plan_id, value: plan.id). Update to also submitplan_name_uidas the primary value, keepingplan_idfor backward compatibility. -
Update test factories and specs to set
plan_name_uidas the primary attribute when creatingplan_limitsrecords
plan_name_uid is the authoritative value on write. plan_id is still populated for backward compatibility with existing readers.
Note (2026-07-24): step 2 was not actually completed by !235913 (merged) —
set_plan_name_uidstill readsreturn if plan_name_uid.present? && !plan_id_changed?and still derives the uid viaplan&.plan_name_uid_before_type_cast. It is carried forward as Phase 2 item 1 below.
Phase 2: Switch All Reads to plan_name_uid
Rescoped 2026-07-24. The original Phase 2 was written before
belongs_to_fixed_itemsexisted; the 2026-07-02 decision in #571923 moved the ActiveRecord association rewrites to Phase B and directs us not to rework merged / in-review Phase A work. Phase 3 moved to #600314. What remains in this issue:
-
PlanLimits(app/models/plan_limits.rb) — rewriteset_plan_name_uidas a reverse sync: takeplan_name_uidas the write input and deriveplan_idfrom it (Plan.find_by(plan_name_uid:)— unique index onplans.plan_name_uid, 12 rows, one cheap indexed lookup), instead of today'splan&.plan_name_uid_before_type_castwhich goes id → uid through the FK. This keepsplan_idpopulated sofk_rails_69f8b6184fand itsNOT NULLstay satisfied, which is what lets the rest of Phase 2 proceed without waiting on any constraint relax. Carried over from Phase 1 step 2. Do this first — item 4 depends on it. -
Admin::PlanLimitsController—✅ done in !246892 (merged). Resolves viaPlan.find_by_plan_name_uid!only; theplan_idfallback and permit entry are gone. -
Admin views (
_ci_cd.html.haml,_package_registry.html.haml) —✅ done in !246892 (merged). The inertplan_idhidden field is removed. -
API endpoint (
lib/api/admin/plan_limits.rb) —current_planstill resolves throughAdmin::PlansFinderby name, returning aPlanrecord and traversing the FK association. Both GET and PUT then callplan.actual_limits(limits || build_limits), so a uid-onlyfind_bywould return nil for a plan with no limits row. Once item 1's reverse sync exists,find_or_initialize_by(plan_name_uid:)saves cleanly and the finder can go. Depends on item 1, nothing else. -
create_or_update_plan_limit(lib/gitlab/database/migration_helpers.rb) — split into two:5a — unblocked now. Add a unique index on
plan_limits.plan_name_uid(index_plan_limits_on_plan_name_uidis non-unique today, soON CONFLICT (plan_name_uid)errors outright) and switch the conflict target off(plan_id). The index is safe to add: the backfill finalized indb/post_migrate/20260420232234_finalize_hk_backfill_plan_limits_plan_name_uid.rb, so the column is fully populated. Keep writingplan_idvia the existing subquery.5b — deferred. Drop the
SELECT … FROM planssubquery and theallow_cross_joins_across_databasescall. This stops writingplan_idoutright, so it is gated on relaxingplan_limits.plan_idNOT NULL (a #600314 slice). Item 1's reverse sync cannot cover this one — migration helpers execute raw SQL outside ActiveRecord callbacks. -
Specs for the above:
spec/models/plan_limits_spec.rb,spec/requests/api/admin/plan_limits_spec.rb,spec/lib/gitlab/database/migration_helpers_spec.rb.
Sequencing: 1 → 4 → 5a are all unblocked today; only 5b waits on #600314. An earlier revision of this description claimed the whole of item 5 was gated on that relax — that conflated the ordinary write flip with the FixedItemsModel repoint. The relax is only unavoidable at the #571923 repoint, when plans no longer exists and there is no id left to sync from. Until then the reverse sync keeps fk_rails_69f8b6184f and its NOT NULL satisfied.
Verified already clean, no work needed (checked against master, 2026-07-24):
Admin::PlanLimits::UpdateService— usesplan.actual_limits, no directplan_idreference.Limitableconcern —global_plan_limits/scoped_plan_limitsboth resolve throughactual_limits.ee/app/models/ee/plan_limits.rb— noplan,plan_id, orplan_name_uidreferences.
Moved out of Phase 2 → #571923 Phase B: repointing Plan.has_one :limits to a uid-keyed lookup, and rewriting the belongs_to :plan instance reads. A fixed-items model cannot own a has_one to a real table, so that rewrite is mandatory there rather than here.
Acceptance: no application code reads plan_id from the plan_limits context. The column is still written but never consumed.
Phase 3: Cleanup
Tracked in #600314 — dropping plan_limits.plan_id, removing the dead associations, and finalizing BackfillPlanLimitsPlanNameUid. (This section previously duplicated #600314 verbatim; #600314 is authoritative.)