Skip to content

fix(migration): make unique-index migration resilient + dedupe data - #2779

Merged
mroderick merged 1 commit into
masterfrom
fix/unique-index-deploy
Aug 4, 2026
Merged

fix(migration): make unique-index migration resilient + dedupe data#2779
mroderick merged 1 commit into
masterfrom
fix/unique-index-deploy

Conversation

@mroderick

@mroderick mroderick commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

PR #2771 added DB unique indexes to back up model validation. But production data already contains duplicate invitations / workshop_invitations / meeting_invitations rows (a Rails uniqueness validation is not atomic, so concurrent double-creates slipped past it). The release-phase migration aborts in prod on a PG::UniqueViolation, and — because it runs with disable_ddl_transaction! — it left 5 of 9 indexes applied with no rollback and the migration un-recorded, blocking every deploy.

What this changes

Migration (20260731104506) — now resilient and safe to re-run against prod's mid-flight state:

  1. Dedupes the three dirty tables first (keeps newest row, tie-break lowest id, so a real attending=true/verified row is never dropped). No-op on clean data. Verified against the production dump: deletes 1105 / 1937 / 55 rows, leaves zero duplicate groups, never drops a sole attending=true row.
  2. Adds if_not_exists: true to all 9 add_index calls, so prod's 5 already-created indexes are skipped and the remaining 4 are created. Wrapped the dedupe execute in safety_assured for strong_migrations.

Because prod never recorded the migration, the next deploy re-runs it and heals itself — no manual prod DB surgery.

RSVP call sites (events/workshops controllers) — switch find_or_create_by / bare Invitation.create to create_or_find_by (atomic INSERT ... ON CONFLICT). Once the unique index backs the validation, the racing double-create would raise unhandled RecordNotUnique → 500; create_or_find_by returns the existing row instead.

Auth callback (auth_services_controller#create) — a double-fired OAuth callback can pass the initial service lookup concurrently, then collide on the unique (uid, provider) index (or its uniqueness validation) during member.save!, erroring the request. Rescue RecordInvalid/RecordNotUnique and reuse the already-created auth service, guarded by find_by! so a non-race failure still surfaces. can_log_in toggles only on actual creation so the losing callback doesn't flip the winner's flag back.

Verification

  • Dedupe SQL validated against the real production dump (dry-run, rolled back).
  • Migration runs end-to-end (clean DB) and re-runs against a mimic-prod state (partial indexes, un-recorded version).
  • Rubocop clean on touched controllers; controller specs for events/workshops/auth all pass, plus a new request spec covering the concurrent-auth race.

Production data has duplicate rows (concurrent double-creates slipped past
the non-atomic Rails uniqueness validations) that block the unique indexes.
Dedupe invitations/workshop_invitations/meeting_invitations first (keep newest
row, tie-break lowest id), and add if_not_exists so the migration re-runs after
its partial first attempt on prod.

Switch RSVP find_or_create_by/Invitation.create to create_or_find_by: once the
unique index backs the validation, the racing double-create raises RecordNotUnique
instead of silently duping.
@mroderick
mroderick merged commit b665ca4 into master Aug 4, 2026
19 checks passed
@mroderick
mroderick deleted the fix/unique-index-deploy branch August 4, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants