Skip to content

fix: add unique index on subscriptions to prevent duplicate entries#2553

Draft
mroderick wants to merge 2 commits intocodebar:masterfrom
mroderick:fix/subscription-unique-index
Draft

fix: add unique index on subscriptions to prevent duplicate entries#2553
mroderick wants to merge 2 commits intocodebar:masterfrom
mroderick:fix/subscription-unique-index

Conversation

@mroderick
Copy link
Copy Markdown
Collaborator

@mroderick mroderick commented Apr 8, 2026

Please review #2551 first, and then this one ... or roll a 1d20 sanity check 🎲

Summary

Adds a database-level unique index on (member_id, group_id) in the subscriptions table to prevent race conditions that cause duplicate subscriptions.

Problem

The Rails model validation validates :group, uniqueness: { scope: :member_id } can be bypassed by race conditions when two concurrent requests both pass validation before either saves to the database.

Evidence from Production

Analysis of production data revealed 25 unique member/group combinations with duplicate subscriptions created within 3ms to 2.5 seconds of each other - classic race condition pattern spanning 2015-2023.

Chapter Duplicate Subscriptions
London 11
West London 6
South London 4
Others 4

Solution

  1. Clean existing duplicates: Delete 31 duplicate subscription records (keep earliest, remove rest)
  2. Add unique index: Create unique index on (member_id, group_id) to enforce at database level

Why (member_id, group_id) not (member_id, chapter_id)?

The index on (member_id, group_id) allows a member to be both a student AND a coach in the same chapter:

  • Each chapter has separate "Students" and "Coaches" groups - each with a different group_id
  • This index prevents duplicate subscriptions to the same group (the race condition bug)
  • But allows valid dual membership: a member can subscribe to both group_id=1 (London Students) AND group_id=2 (London Coaches)

Using (member_id, chapter_id) would incorrectly prevent the student+coach combination.

Migration Details

  • Uses disable_ddl_transaction! to allow concurrent index creation
  • Uses algorithm: :concurrently to avoid table locks during production deployments
  • Removes duplicate subscriptions using raw SQL (skips Rails callbacks to avoid triggering mailing list unsubscribes)

Testing

  • All existing tests pass
  • Invitation manager deduplication tests pass (3 examples, 0 failures)

Related

Members subscribed to multiple groups within a chapter were being
processed multiple times when sending invitations with audience='everyone',
causing 'Member has already been taken' validation errors.

Root Cause:
- Member.in_group() uses JOIN on subscriptions, returning duplicate rows
  when a member is subscribed to the same group type multiple times
- 336 members in London chapter affected (subscribed to both Students and Coaches)
- 35 members in Berlin chapter affected
- 57 members in Brighton chapter affected

Impact:
- Workshop invitation batches failed mid-process (e.g., 517 of 7000+
  invitations sent before failure)
- InvitationLogEntry validates uniqueness of member_id per invitation,
  causing validation errors on second processing attempt
- Admins had to manually retry batch sends multiple times

Affected Chapters:
- 46 of 54 chapters (85%) have members with duplicate subscriptions
- ~1,200 members affected across all chapters
- Top 5: London (336), West London (79), Brighton (57), South London (57),
  Barcelona (55), Edinburgh (37), Berlin (35)

Timeline:
- Original code (Jan 2017, commit 448acc7) included .uniq for deduplication
- Refactoring (May 2018, commit d7a463d) extracted to scopes but
  accidentally removed the .uniq call

Fix: Add .distinct to chapter_students and chapter_coaches methods,
restoring the deduplication behavior that existed before the refactoring.

Tests:
- Add spec for unique members when subscribed to same group multiple times
- Add spec for unique invitations per role when member is in both groups
@mroderick mroderick force-pushed the fix/subscription-unique-index branch 2 times, most recently from c62c0bd to 86f89d3 Compare April 8, 2026 20:15
- Uses disable_ddl_transaction! (https://api.rubyonrails.org/classes/ActiveRecord/Migration.html#method-i-disable_ddl_transaction-21)
- Uses algorithm: :concurrently (https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_index)
- Removes 31 duplicate subscriptions from production database
- Prevents race conditions from causing duplicate entries in future

Why (member_id, group_id) not (member_id, chapter_id)?
- Index on (member_id, group_id) allows a member to be both student AND coach in the same chapter
- Each chapter has separate "Students" and "Coaches" groups - different group_id values
- This index prevents duplicate subscriptions to the SAME group (the race condition bug)
- But allows valid dual membership: member can subscribe to group_id=1 (Students) AND group_id=2 (Coaches)
@mroderick mroderick force-pushed the fix/subscription-unique-index branch from 86f89d3 to d881296 Compare April 8, 2026 20:16
@mroderick mroderick marked this pull request as draft April 8, 2026 20:30
Copy link
Copy Markdown
Collaborator

@olleolleolle olleolleolle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

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