Skip to content

fix: handle duplicate InvitationLogEntry on retry (v2)#2558

Merged
mroderick merged 3 commits intocodebar:masterfrom
mroderick:fix/invitation-log-retry-v2
Apr 10, 2026
Merged

fix: handle duplicate InvitationLogEntry on retry (v2)#2558
mroderick merged 3 commits intocodebar:masterfrom
mroderick:fix/invitation-log-retry-v2

Conversation

@mroderick
Copy link
Copy Markdown
Collaborator

@mroderick mroderick commented Apr 9, 2026

Summary

Second attempt at fixing the "Member has already been taken" validation error when re-sending workshop invitations.

Problem

The previous fix (PR #2556) used find_or_initialize_by with status included in the query:

@log.entries.find_or_initialize_by(member: member, invitation: invitation, status: :success)

However, the InvitationLogEntry uniqueness constraint is:

validates :member_id, uniqueness: { scope: %i[invitation_type invitation_id] }

This validates (member_id, invitation_type, invitation_id), NOT status. The bug:

  1. First invitation attempt → logs success → entry saved with status = success
  2. Second invitation attempt → find_or_initialize_by(member, invitation, :failed) → finds the success entry but with wrong status → tries to save → VALIDATION ERROR

The previous fix worked for most cases but failed when:

  • A member had both a success and failure entry for the same invitation
  • The status query returned an entry with a different status than expected

Solution

Split the check - query by (member, invitation) only, ignoring status:

def find_or_build_entry(member, invitation, status)
  # First check: any existing entry for this member+invitation?
  existing_entry = @log.entries.find_by(member: member, invitation: invitation)
  return existing_entry if existing_entry

  # No entry exists - create new one
  @log.entries.new(
    member: member,
    invitation: invitation,
    status: status
  )
end

This ensures:

  • If any entry exists (success OR failure), return it
  • Only create new entry if none exists
  • The uniqueness constraint is never violated

Additional Fix

Also add autosave: false to the has_many :entries association in InvitationLog:

has_many :entries, class_name: 'InvitationLogEntry', dependent: :destroy, autosave: false

Why: In Rails 5+, has_many defaults to autosave: true. When fail_batch calls @log.update!, Rails attempts to autosave all associated entries. If any unpersisted entries exist in memory with validation issues, this causes Validation failed: Entries is invalid errors.

This happened when I tried to re-invite people for the Berlin chapter this morning:

https://app.rollbar.com/a/codebar-production/fix/item/codebar-production/674#detail

image

Changes

  • app/services/invitation_logger.rb - Separate find/build logic to query without status
  • app/models/invitation_log.rb - Add autosave: false to entries association

Testing

41 examples pass, including retry behavior tests.

Second attempt at fixing the "Member has already been taken" validation error
when re-sending workshop invitations.

Problem:
The previous fix (PR codebar#2556) used find_or_initialize_by with the status included
in the query. However, the InvitationLogEntry uniqueness constraint only validates
(member_id, invitation_type, invitation_id), NOT status. This means:

- A member can have ONE success entry AND ONE failure entry for the same invitation
- find_or_initialize_by(member, invitation, :success) would return a persisted
  entry with status=:failed if one existed, causing the wrong status to be used
- Or if building a new entry with status=:success when status=:failed already
  existed, the uniqueness validation would fail

Fix:
1. Split the check: first find any existing entry by (member, invitation),
   ignoring status
2. If found, return it (either success or failure)
3. Only if no entry exists, create a new one with the specified status

Additionally, add autosave: false to the has_many :entries association to prevent
Rails from attempting to autosave unpersisted entries when fail_batch is called,
which was causing "Validation failed: Entries is invalid" errors.

Changes:
- app/services/invitation_logger.rb: Separate find_or_build_entry logic
- app/models/invitation_log.rb: Add autosave: false to entries association

Tests:
- All 41 existing tests pass
- Retry behavior tests verify no duplicate entries are created
@mroderick mroderick requested a review from olleolleolle April 9, 2026 15:57
Verify that when logging success/failure for a member+invitation that
already has an entry with a different status, the existing entry is
returned (not a new one created, which would cause uniqueness violation)
@olleolleolle
Copy link
Copy Markdown
Collaborator

Perhaps the find_or... with a block (the block is used to set attributes on the non-found record)

https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-find_or_create_by

# Find the first user named "Scarlett" or create a new one with a
# particular last name.
User.find_or_create_by(first_name: 'Scarlett') do |user|
  user.last_name = 'Johansson'
end

Refactored per olleolleolle's suggestion:
- Use find_or_create_by(member, invitation) with block for status
- Check processed_at instead of persisted? for retry detection
- Cleaner, more idiomatic Rails pattern
@mroderick
Copy link
Copy Markdown
Collaborator Author

Thanks for the suggestion! Your insight led to a cleaner solution.

The issue with find_or_create_by + block is that it persists immediately, so the existing persisted? check wouldn't work. But we can instead check processed_at - once an entry is processed, it shouldn't be counted again:

def find_or_build_entry(member, invitation, status)
  @log.entries.find_or_create_by(member: member, invitation: invitation) do |entry|
    entry.status = status
  end
end

def log_success(member, invitation = nil)
  entry = find_or_build_entry(member, invitation, :success)
  return entry if entry.processed_at  # Already processed → skip

  entry.assign_attributes(processed_at: Time.current)
  save_entry(entry, :success_count)
end

This is more idiomatic Rails - the block only runs on create, not on find. And using processed_at as the "already handled" flag works regardless of status, handling both same-status and cross-status retries correctly.

Appreciate the nudge to find the cleaner approach!

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.

🌟

@mroderick mroderick merged commit 44c2ff9 into codebar:master Apr 10, 2026
7 checks passed
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