fix: handle duplicate InvitationLogEntry creation on retry#2556
fix: handle duplicate InvitationLogEntry creation on retry#2556mroderick merged 2 commits intocodebar:masterfrom
Conversation
When re-sending workshop invitations, the InvitationLogger would attempt to create a new InvitationLogEntry for members with existing invitations, causing a uniqueness validation error: 'Member has already been taken'. This fix uses Rails' idiomatic find_or_initialize_by pattern to check if an entry already exists before creating a new one. If the entry exists (meaning the member was already processed), it returns the existing entry without incrementing the counter. Root cause: - InvitationLogEntry validates uniqueness on (member_id, invitation_type, invitation_id) - Re-sending invitations to the same workshop would try to log success for an already-logged member+invitation combination - This caused batch jobs to fail mid-process with validation errors Affected scenarios: - Re-sending invitations for the same workshop - Sending invitations with audience='everyone' (processes both students and coaches, where a member might be in both groups) - Retrying failed invitation batches Fix: - Replace create! with find_or_initialize_by in log_success, log_failure, and log_skipped methods - Return existing entry if already persisted - Only increment counter when creating a new entry - Extract shared helper methods for cleaner code Tests: - Add retry tests for all three logging methods to verify no duplicate entries are created on repeated calls with the same member+invitation
|
The send invitation functionality still fails for workshops, where we've previously sent out invitations, even after #2553 has been deployed.
|
olleolleolle
left a comment
There was a problem hiding this comment.
Sounds like an improvement!
Use modern ruby :) Co-authored-by: Olle Jonsson <olle.jonsson@gmail.com>
The irony is that this was introduced by me in #2546 ... no good deed goes unpunished 😉 |
| end | ||
|
|
||
| def save_entry(entry, counter) | ||
| entry.save! |
There was a problem hiding this comment.
Hi @till,
Good question! I have considered this carefully, and I believe save! is the right choice here. Let me explain:
Why the current approach is correct:
-
This fix prevents the original error - By using
find_or_initialize_by, we check for existing entries before creating. The original "Member has already been taken" error happened because we blindly calledcreate!without checking. This fix solves that root cause. -
The race condition is very unlikely - The only scenario where
save!would raise is if two processes both calledfind_or_initialize_bysimultaneously, both got an unpersisted record, and both tried to save. In practice, invitations are processed by a single Delayed Job worker, so this is extremely rare. -
Silently swallowing errors would be worse - If we catch and skip on failure, we lose visibility into potential issues. The uniqueness constraint exists as a safety net - if something unexpected happens, we want to know.
-
The log entries matter - Unlike optional debug logging, these entries track invitation delivery for audit purposes and debugging delivery issues. We want to know if something goes wrong.
Happy to discuss further if you think there is a real scenario I am missing!
| end | ||
|
|
||
| def save_entry(entry, counter) | ||
| entry.save! |
There was a problem hiding this comment.
Hi @till,
Good question! I have considered this carefully, and I believe save! is the right choice here. Let me explain:
Why the current approach is correct:
-
This fix prevents the original error - By using
find_or_initialize_by, we check for existing entries before creating. The original "Member has already been taken" error happened because we blindly calledcreate!without checking. This fix solves that root cause. -
The race condition is very unlikely - The only scenario where
save!would raise is if two processes both calledfind_or_initialize_bysimultaneously, both got an unpersisted record, and both tried to save. In practice, invitations are processed by a single Delayed Job worker, so this is extremely rare. -
Silently swallowing errors would be worse - If we catch and skip on failure, we lose visibility into potential issues. The uniqueness constraint exists as a safety net - if something unexpected happens, we want to know.
-
The log entries matter - Unlike optional debug logging, these entries track invitation delivery for audit purposes and debugging delivery issues. We want to know if something goes wrong.
Happy to discuss further if you think there is a real scenario I am missing!

Summary
Fixes the "Member has already been taken" validation error when re-sending workshop invitations or sending to audiences that include members with existing invitations.
Problem
When admins re-send invitations for a workshop, the
InvitationLoggerwould attempt to create a newInvitationLogEntryfor members who already had log entries from previous sends. This caused a uniqueness validation failure:This error would cause the invitation batch job to fail mid-process, requiring manual retries.
Root Cause
InvitationLogEntryhas a uniqueness validation on(member_id, invitation_type, invitation_id):When re-sending invitations:
find_or_create_byreturns the existing invitation (no error)logger.log_success(member, invitation)tries to create a new log entryAffected Scenarios
audience='everyone'where a member is in both Students and Coaches groupsSolution
Replace
create!withfind_or_initialize_byin the logging methods:This is the idiomatic Rails pattern for handling "create if not exists" scenarios.
Changes
app/services/invitation_logger.rb- Core fix with helper methodsspec/services/invitation_logger_spec.rb- Added retry tests for all three logging methodsTesting
All tests pass (41 examples):
Related
This fix works in conjunction with PR #2551 (fix: deduplicate members in chapter_students and chapter_coaches) which addressed a different aspect of the duplicate invitation issue - preventing duplicate member processing at the chapter group level.
Together, these fixes ensure: