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.

  1. Confirm the backfill migration BackfillPlanLimitsPlanNameUid has completed on production

  2. Update set_plan_name_uid in app/models/plan_limits.rb: Make plan_name_uid the primary write. Continue writing plan_id for backward compatibility, but derive the uid from the in-memory Plan::PLAN_NAME_UID_LIST instead of calling plan&.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
    end

    Update to derive plan_name_uid from the plan name using Plan::PLAN_NAME_UID_LIST directly, and keep plan_id populated for backward compatibility.

  3. Update create_or_update_plan_limit migration helper in lib/gitlab/database/migration_helpers.rb: Currently uses INSERT INTO plan_limits (plan_id, ...) SELECT id, ... FROM plans WHERE name = ... ON CONFLICT (plan_id). Update to also write plan_name_uid using the in-memory enum, and prepare for the eventual switch to ON CONFLICT (plan_name_uid). Keep plan_id populated for backward compatibility.

  4. Update Admin::PlanLimitsController (app/controllers/admin/plan_limits_controller.rb): Currently does Plan.find(plan_limits_params[:plan_id]).actual_limits. Update set_plan_limits to also accept plan_name_uid as the primary identifier, keeping plan_id supported for backward compatibility.

  5. Update admin views (app/views/admin/application_settings/_ci_cd.html.haml and app/views/admin/application_settings/_package_registry.html.haml): Currently use f.hidden_field(:plan_id, value: plan.id). Update to also submit plan_name_uid as the primary value, keeping plan_id for backward compatibility.

  6. Update test factories and specs to set plan_name_uid as the primary attribute when creating plan_limits records

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_uid still reads return if plan_name_uid.present? && !plan_id_changed? and still derives the uid via plan&.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_items existed; 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:

  1. PlanLimits (app/models/plan_limits.rb) — rewrite set_plan_name_uid as a reverse sync: take plan_name_uid as the write input and derive plan_id from it (Plan.find_by(plan_name_uid:) — unique index on plans.plan_name_uid, 12 rows, one cheap indexed lookup), instead of today's plan&.plan_name_uid_before_type_cast which goes id → uid through the FK. This keeps plan_id populated so fk_rails_69f8b6184f and its NOT NULL stay 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.

  2. Admin::PlanLimitsController — ✅ done in !246892 (merged). Resolves via Plan.find_by_plan_name_uid! only; the plan_id fallback and permit entry are gone.

  3. Admin views (_ci_cd.html.haml, _package_registry.html.haml) — ✅ done in !246892 (merged). The inert plan_id hidden field is removed.

  4. API endpoint (lib/api/admin/plan_limits.rb) — current_plan still resolves through Admin::PlansFinder by name, returning a Plan record and traversing the FK association. Both GET and PUT then call plan.actual_limits (limits || build_limits), so a uid-only find_by would 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.

  5. 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_uid is non-unique today, so ON CONFLICT (plan_name_uid) errors outright) and switch the conflict target off (plan_id). The index is safe to add: the backfill finalized in db/post_migrate/20260420232234_finalize_hk_backfill_plan_limits_plan_name_uid.rb, so the column is fully populated. Keep writing plan_id via the existing subquery.

    5b — deferred. Drop the SELECT … FROM plans subquery and the allow_cross_joins_across_databases call. This stops writing plan_id outright, so it is gated on relaxing plan_limits.plan_id NOT NULL (a #600314 slice). Item 1's reverse sync cannot cover this one — migration helpers execute raw SQL outside ActiveRecord callbacks.

  6. 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 — uses plan.actual_limits, no direct plan_id reference.
  • Limitable concern — global_plan_limits / scoped_plan_limits both resolve through actual_limits.
  • ee/app/models/ee/plan_limits.rb — no plan, plan_id, or plan_name_uid references.

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.)

Edited by 🤖 GitLab Bot 🤖