Fix keys.organization_id NOT NULL constraint failure on Self-Managed/Dedicated upgrades

What does this MR do and why?

Fixes a PG::CheckViolation crash that prevents Self-Managed and Dedicated instances from upgrading when the 20260422180000 AddNotNullConstraintToKeysOrganizationId post-deployment migration runs.

Root cause

The BackfillOrganizationIdKeys batched background migration (BBM) backfills SSH keys via:

UPDATE keys
SET organization_id = users.organization_id
FROM users
WHERE keys.user_id = users.id
  AND keys.organization_id IS NULL

SSH keys with NULL user_id (e.g. keys created before the user association was enforced) or a user_id pointing to a deleted user are silently skipped by this join. When the subsequent AddNotNullConstraintToKeysOrganizationId migration tries to validate the CHECK (organization_id IS NOT NULL) constraint, it fails:

PG::CheckViolation: check constraint "check_8933ae4f38" of relation "keys"
is violated by some row

Deploy keys already had an orphan fallback (backfill_orphaned_deploy_keys assigns the default organization), but regular SSH keys did not.

Fix

This MR mirrors the approach used in !242492 (merged) for oauth_applications:

  1. No-op 20260422180000_add_not_null_constraint_to_keys_organization_id.rb — the migration is replaced by two later migrations so it cannot be fixed in place without backdating.
  2. New backfill 20260714000001_backfill_null_organization_id_on_keys.rb — iterates keys rows with organization_id IS NULL in batches of 1 000 and sets them to the default organization (id = 1). On GitLab.com all rows were already backfilled by the BBM, so this is a no-op there.
  3. New constraint 20260714000002_add_not_null_constraint_to_keys_org_id.rb — re-adds the NOT NULL constraint. add_not_null_constraint is idempotent, so on GitLab.com (where the constraint already exists from the original migration) this is a no-op.

Customer workaround automated by this MR

Customers who hit this issue were advised to run:

UPDATE keys SET organization_id = 1 WHERE organization_id IS NULL;

This MR automates that fix safely, in batches, as part of the upgrade path.

References

Screenshots or screen recordings

N/A — database migration only.

Before After

How to set up and validate locally

  1. Create a keys row with organization_id = NULL (requires dropping the check constraint first):

    ALTER TABLE keys DROP CONSTRAINT IF EXISTS check_8933ae4f38;
    INSERT INTO keys (user_id, title, key, fingerprint, organization_id)
    VALUES (NULL, 'orphan', 'ssh-rsa AAAA...', 'aa:bb:cc', NULL);
  2. Run the backfill migration:

    bundle exec rails db:migrate:up VERSION=20260714000001
  3. Verify the row now has organization_id = 1.

  4. Run the constraint migration:

    bundle exec rails db:migrate:up VERSION=20260714000002
  5. Confirm no PG::CheckViolation is raised.

Database review: queries and query plans

Documenting the SQL and query plans for the batched data backfill in 20260714000001 (per the database review guidelines for bulk update_all operations).

SQL executed

The migration iterates keys rows with organization_id IS NULL in batches of 1,000 (each_batch) and updates each batch to the default organization:

-- batch boundary lookup (each_batch)
SELECT keys.id
FROM keys
WHERE keys.organization_id IS NULL
ORDER BY keys.id ASC
LIMIT 1 OFFSET 1000;

-- per-batch update
UPDATE keys
SET organization_id = 1
WHERE keys.organization_id IS NULL
  AND keys.id >= :batch_start
  AND keys.id < :batch_end;

20260714000002 runs DDL only (add_not_null_constraint :keys, :organization_id, i.e. ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT); it is idempotent and a no-op where the constraint already exists.

Index usage

The organization_id IS NULL predicate is served by the existing btree index index_keys_on_organization_id — PostgreSQL btree indexes include NULLs, so this is not a sequential scan of keys:

EXPLAIN SELECT COUNT(*) FROM keys WHERE organization_id IS NULL;

Aggregate  (cost=3.19..3.20 rows=1 width=8)
  ->  Index Only Scan using index_keys_on_organization_id on keys  (cost=0.15..3.18 rows=2 width=0)
        Index Cond: (organization_id IS NULL)

(Plan captured on a local development database; representative GitLab.com-scale numbers will come from the db:gitlabcom-database-testing pipeline. The relevant fact — index usage for organization_id IS NULL rather than a seq scan — is shown by the plan above.)

Expected cost

  • GitLab.com: the NOT NULL check constraint is already present and validated, so keys cannot contain organization_id IS NULL rows — the backfill matches nothing (effective no-op).
  • Self-Managed / Dedicated: cost is proportional to the small residual NULL set, processed in batches of 1,000 with disable_ddl_transaction! so each batch commits independently and holds no long locks.

Remaining pre-merge steps: request a database review and trigger the db:gitlabcom-database-testing pipeline.

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.

Edited by Smriti Garg

Merge request reports

Loading