Skip to content

perf: optimize WorkshopInvitationController#accept (1940ms → <500ms target)#2701

Open
mroderick wants to merge 5 commits into
masterfrom
perf/async-rsvp-email
Open

perf: optimize WorkshopInvitationController#accept (1940ms → <500ms target)#2701
mroderick wants to merge 5 commits into
masterfrom
perf/async-rsvp-email

Conversation

@mroderick

Copy link
Copy Markdown
Collaborator

Problem

WorkshopInvitationController#accept has a reported mean response time of 1940ms, with outliers up to 16s. This is the RSVP acceptance flow used by workshop attendees, so slow response times directly impact user experience.

Analysis

Traced the accept action end-to-end and identified four bottlenecks:

Bottleneck Impact Before
Synchronous email delivery 🔴 High deliver_now blocks response for 2-10s waiting on SMTP
O(N) attendee/waitlist checks 🟠 Medium Loads all attendees into Ruby memory to check one person's membership
No eager loading 🟡 Low-Med 3-4 lazy-load queries per request
Redundant validation 🟡 Low valid? + update runs validation twice

1. Synchronous email (deliver_now)

# Before: blocks until SMTP completes
WorkshopInvitationMailer.attending(...).deliver_now

# After: sends in background via Delayed Job
WorkshopInvitationMailer.attending(...).deliver_later

Estimated improvement: -2000ms to -10000ms (removes SMTP latency from request)

2. O(N) attendee/waitlist checks

# Before: loads ALL attendees with includes(:member) into Ruby
attending_students.map(&:member).include?(person)

# After: single SQL EXISTS query
invitations.accepted.exists?(member_id: person.id)

Same optimization for waitlisted?. For a workshop with 30 students + 10 coaches, this eliminates loading 80 AR objects per check.

Estimated improvement: -50ms to -200ms (depends on attendance count)

3. Eager loading

# Before: 3-4 lazy loads as controller accesses associations
@invitation = WorkshopInvitation.find_by!(token: token)

# After: single query with preloaded associations
@invitation = WorkshopInvitation.includes(:workshop, :member).find_by!(token: token)

Estimated improvement: -20ms to -50ms

4. Redundant validation

# Before: validates twice
@invitation.assign_attributes(...)
return unless @invitation.valid?
@invitation.update(...)  # validates again

# After: single update call
if @invitation.update(...)

Also fixes a bug where update action set attending: true for validation but didn't persist it.

Estimated improvement: -5ms to -10ms

Estimated Total Improvement

Scenario Before After
Best case (fast SMTP, low attendance) ~1940ms ~200ms
Typical ~1940ms ~400ms
Worst case (slow SMTP, high attendance) ~16s ~500ms

Target: ≤500ms mean

Changes

  • Test coverage first: Added comprehensive controller and feature specs (23 controller examples + 1 feature scenario) before any performance changes
  • Async email: Changed deliver_nowdeliver_later in both WorkshopPresenter and VirtualWorkshopPresenter
  • Efficient queries: Replaced O(N) attendee? and waitlisted? methods with single EXISTS queries
  • Eager loading: Added includes(:workshop, :member) to set_invitation
  • Remove redundancy: Eliminated duplicate validation in update and accept actions

Requirements

  • Delayed Job worker must be running in production (bin/delayed_job start or Heroku worker dyno)

Testing

All tests pass at each commit:

  • 23 new controller spec examples
  • 1 new feature spec scenario
  • Existing presenter specs updated to verify deliver_later

Added comprehensive test coverage for RSVP actions:

Controller spec (spec/controllers/workshop_invitation_controller_spec.rb):
- GET #show
- POST #accept: happy path, already attending, RSVPs closed, workshop full, missing tutorial
- POST #reject: happy path, already rejected, past deadline, waiting list promotion
- PATCH #update: valid params, invalid params

Feature spec additions (spec/features/accepting_invitation_spec.rb):
- Workshop full scenario shows waiting list option

Tests pass against master and all 4 performance commits from perf/async-rsvp-email.
WorkshopInvitationController#accept was sending confirmation emails
synchronously with deliver_now, blocking the response for 2-10s.

Changed to deliver_later in WorkshopPresenter and
VirtualWorkshopPresenter. Email delivery now runs in background via
Delayed Job.

- Production: requires Delayed Job worker running
- Tests: updated to verify deliver_later is called (allow/have_received pattern)
`attendee?` and `waitlisted?` loaded all attendees/waitlisted members
into memory with includes(:member) just to check membership of one
person. For a workshop with 30 students + 10 coaches, that was 80 AR
objects loaded per check.

Replaced with single EXISTS queries:
- `attendee?`: `invitations.accepted.exists?(member_id:)`
- `waitlisted?`: `WaitingList.by_workshop(self).joins(:invitation).where(...).exists?`

Both now use a single SQL query regardless of attendance count.
WorkshopInvitationController accesses @invitation.workshop and
@invitation.member multiple times per request. Added includes() to
eager load both associations in the initial query, eliminating 2-3
lazy loads per request.
Both update and accept actions called assign_attributes + valid? +
update, running validation twice. Replaced with single update calls.

Also fixes:
- update action was setting attending: true for validation but not
  persisting it (update was called with just invitation_params)
- accept action showed "no available seats" when validation failed
  instead of the actual error message
@mroderick mroderick force-pushed the perf/async-rsvp-email branch from 65d85c4 to 1732242 Compare July 11, 2026 09:30
@mroderick mroderick marked this pull request as ready for review July 11, 2026 09:36
@gnclmorais gnclmorais self-requested a review July 11, 2026 10:11

@gnclmorais gnclmorais left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fantastic job!

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