Skip to content

Allow post-close cancellations with frozen waitlist progression - #3003

Merged
mroderick merged 7 commits into
codebar:masterfrom
mroderick:feat/post-close-rsvp-waitlist
Oct 12, 2026
Merged

mroderick merged 7 commits into
codebar:masterfrom
mroderick:feat/post-close-rsvp-waitlist

Conversation

@mroderick

@mroderick mroderick commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Once a custom RSVP close time passes, members on the main list can still cancel, and each cancellation fills the freed seat from the waiting list until a hard freeze 3.5 hours before the workshop starts. New joins stay blocked from the close time onward. Closes #2990.

  • Waiting-list join and leave stop at the earlier of the custom close time and the 3.5-hour freeze. Cancellation and promotion stop at the freeze.
  • A cancel frees a seat only when the invitation held one, and promotion takes the first auto-RSVP waiting-list entry in created order.
  • Admin removals use the same promotion path, and only while the workshop is in the future.
  • Invitation pages hide join, leave, and cancel controls once the matching gate has passed.

Review notes

  • Start with the seat claim and the promotion pop. release_seat claims the seat under a row lock, and WaitingList.promote_next pops the head in a transaction with FOR UPDATE SKIP LOCKED. The two must stay consistent: a repeated cancel must not free the same seat twice, and two promotions must not take the same entry. Test suites cannot prove that interleaving, so this part needs the closest read.
  • Gate semantics: join and leave use the earlier of the custom close time and the freeze, cancel and promotion use the freeze alone. Actions stop at the instant, not after it.
  • Deliberately not done: a reject admitted just before the freeze can still promote and send email after it, because the gate is read once at request start. Re-checking it after the seat claim is a product call. Admin additions ignore the close times. Cancelling from the waiting list keeps the waiting-list entry.
Behaviour by action
Action Stops at
Join the main list or the waiting list The earlier of the custom close time and the 3.5-hour freeze
Leave the waiting list Same as join
Cancel a main-list spot The 3.5-hour freeze
Promote from the waiting list after a cancel The 3.5-hour freeze for member cancels, the workshop start for admin removals

The cross-role promotion spec calls promote_next. This change replaces WaitingList.next_spot with promote_next, which confirms the invitation and removes the waiting-list entry in one step.

@mroderick
mroderick force-pushed the feat/post-close-rsvp-waitlist branch 2 times, most recently from 83bc64f to 0d1a071 Compare October 9, 2026 09:05
@mroderick
mroderick marked this pull request as ready for review October 9, 2026 09:16
@mroderick
mroderick requested a review from olleolleolle October 9, 2026 09:16
Comment thread app/models/waiting_list.rb Outdated
return unless next_spot

invitation = next_spot.invitation
next_spot.destroy

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.

as we're using update! on line :37 I think it makes sense to also hard error if a record doesn't get destroyed by using destroy! here

@mroderick mroderick Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Changed to destroy! in f3f1988. It fixes more than style: promote_next already raises through update! when confirming the invitation fails, so a silent destroy failure would leave the entry on the waiting list while the invitation becomes attending. The entry would stay at the head of the list and a later promotion could confirm the same invitation again. destroy! raises and rolls the transaction back instead.

Thank you 🙏

include InstanceMethods
end

module InstanceMethods

@olleolleolle olleolleolle Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: are we using the extra level of indirection here, for testing, for example, or is it just an extra level?

That is, it is possible to enter def directly in the included block.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Flattened in d7f6ad4. The included do / include InstanceMethods shape exists for callbacks, and this concern defines none, so it is now a plain module with the methods declared directly and private at the top. (The same shape in the sibling concerns stays as-is: WorkshopInvitationConcerns needs the included block for its before_action.)

# Releases the invitation's seat under a row lock so two concurrent
# cancellation requests cannot each believe they freed a seat. Returns true
# only when this request moved a seat-holder to not attending.
def release_seat(additional_attributes = {})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is it possible to use kwargs, ** here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in d7f6ad4: def release_seat(**attributes). Both call sites already passed kwargs, so nothing else changed. I checked the rest of the codebase for the same pattern and filed the remaining instances as #3009 rather than converting them all here.

= f.input :tutorial, collection: @tutorial_titles, include_blank: true
= f.input :note, required: false, input_html: { rows: 3, maxlength: 100 }, hint: 'Anything else we should know?', placeholder: 'e.g. I need help understanding selectors'
= f.button :button, 'Join the waiting list', class: 'btn btn-primary w-100 mb-0'
- if @workshop.waitlist_open?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: this partial can take Strict Locals.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added -# locals: (invitation:) in d7f6ad4. Both render sites in show.html.haml already pass the local. Note the partial still reads @workshop, @invitation, and @tutorial_titles from ivars, so the guarantee covers the invitation local only.

@olleolleolle olleolleolle left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good gap-closing!

mroderick added a commit to mroderick/codebar-planner that referenced this pull request Oct 12, 2026
Apply Olle's review comments on PR codebar#3003. The concern defines no
callbacks, so it drops ActiveSupport::Concern and the InstanceMethods
split for a plain private module. release_seat takes keyword arguments
now; both call sites already passed kwargs. The waiting list partial
declares strict locals; its only render sites already pass invitation.

No behaviour change, suite unchanged at 1714 examples.
Adds the two time gates from issue codebar#2990. waitlist_closes_at is the earlier of the custom RSVP close time and the freeze, so new joins stop at the configured close. cancellations_open? tracks the freeze alone, so a member may cancel after the close and until the freeze. The freeze is the hard cutoff 3.5 hours before the start, requirement 4 of the issue, and rsvp_freezes_at owns that offset alone. The RSVP flows that apply these gates follow in the next commit.
A cancel frees a seat only when the invitation held one, so rejecting an invitation that never answered must not consume a waiting-list entry. release_seat claims the seat with a row-locked read-modify-write, so duplicate concurrent cancels free it once. promote_next pops the head in a transaction with FOR UPDATE SKIP LOCKED so concurrent promoters take different entries, and confirms with update! so a failed confirmation rolls the pop back instead of emailing a spot that was never granted.

The FIFO order and the seat-freed rule are pinned by specs. The locking behaviour cannot be exercised by the single-threaded suite, so this message is where it is recorded. WaitingList.next_spot still serves the current RSVP flows until the next commit replaces it.
…eeze

Once the custom RSVP close time passes, new joins to the main list and the waiting list stay blocked, while main-list members may cancel until the freeze. Each cancel that frees a seat promotes the first auto-RSVP waiting-list entry in created order. Admin removals use the same promotion path while the workshop is in the future.

This commit replaces WaitingList.next_spot in the RSVP flows and removes it, now that promote_next covers it. The end-to-end pins cover the reject path, the admin removal path, and the waitlist join and leave gates.
promote_next already raises through update! when confirming the
invitation fails, so a silent destroy failure left the entry on the
waitlist while the invitation became attending, allowing a repeat
promotion and duplicate attending email. destroy! rolls the whole
transaction back instead.
Apply Olle's review comments on PR codebar#3003. The concern defines no
callbacks, so it drops ActiveSupport::Concern and the InstanceMethods
split for a plain private module. release_seat takes keyword arguments
now; both call sites already passed kwargs. The waiting list partial
declares strict locals; its only render sites already pass invitation.

No behaviour change, suite unchanged at 1714 examples.
promote_next dropped the self-exclusion guard that PR codebar#2997 built into
the reject flow with find_by(...)&.destroy before the next-spot lookup.
With the cancelling member's own entry first (often the only one), the
promotion confirmed their just-cancelled invitation and emailed them an
attendance notice instead of reaching the real next member.

promote_next now takes excluding:, and both callers pass the invitation
being cancelled. Update the controller examples: the rejecter's entry
stays on the list until they leave it, rather than being consumed by
their own cancellation.
@mroderick
mroderick force-pushed the feat/post-close-rsvp-waitlist branch from d7f6ad4 to 02afcec Compare October 12, 2026 10:34
@mroderick
mroderick merged commit 1f2778e into codebar:master Oct 12, 2026
10 checks passed
mroderick added a commit to mroderick/codebar-planner that referenced this pull request Oct 12, 2026
Encodes the keyword-arguments-over-option-hashes preference from the PR codebar#3003 review for code written from now on. Issue codebar#3009.
mroderick added a commit to mroderick/codebar-planner that referenced this pull request Oct 12, 2026
Encodes the keyword-arguments-over-option-hashes preference from the PR codebar#3003 review for code written from now on. Issue codebar#3009.
@mroderick
mroderick deleted the feat/post-close-rsvp-waitlist branch October 12, 2026 16:45
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.

Update custom RSVP closure logic: Enable cancellations and waitlist progression until 3.5h before event

3 participants