feat(multiplayer): cascade a queue-ticket cancel into an open proposal

The last two commits fixed the severe stranding bug in decline and
timeout, but left a real responsiveness gap: cancelling a ticket
directly while it's part of an OPEN proposal used to leave the OTHER
participant waiting out the full response window for something the
system already knew couldn't happen -- their proposal partner just
abandoned the queue. ProposalExpireRequeueSQL eventually rescues them,
but only after the full window elapses, not immediately.

CascadeCancelToOpenProposal runs inside the same transaction as the
cancel itself: if the cancelled ticket belonged to a currently-OPEN
proposal, decline that proposal right now and requeue every other
participant immediately via the same ProposalDeclineRequeueSQL the
decline path already uses. The cancelling player's own ticket
correctly stays CANCELLED, not swept back into the requeue meant for
everyone else (ProposalDeclineRequeueSQL only touches tickets still at
PROPOSED).

Covered by a real PostgreSQL integration test: cancelling one
participant's ticket mid-proposal immediately declines the proposal
and requeues the other participant with a refreshed expiry, while the
cancelling player's own ticket stays CANCELLED. First draft used a
stale expected revision (0) for the cancel call -- CreateProposal's
own QueueTicketProposeSQL already bumps a ticket's revision to 1 when
forming the proposal, caught immediately by actually running the test
against real Postgres rather than assuming. Clean across 5 runs after
the fix, plus the full integration and unit suites.
This commit is contained in:
Josh Creek
2026-09-01 14:40:30 +01:00
parent fe0a0b72a8
commit 79318b56bd
4 changed files with 107 additions and 0 deletions
+29
View File
@@ -37,6 +37,35 @@ FROM proposal_participants pp
WHERE pp.proposal_id = $1 AND q.ticket_id = pp.ticket_id AND q.player_id = pp.player_id AND q.state = 'PROPOSED'
AND EXISTS (SELECT 1 FROM proposals WHERE proposals.proposal_id = $1 AND proposals.state = 'EXPIRED')`
const OpenProposalForCancelledTicketSQL = `SELECT pp.proposal_id
FROM proposal_participants pp
JOIN proposals p ON p.proposal_id = pp.proposal_id
WHERE pp.ticket_id = $1 AND pp.player_id = $2 AND p.state = 'OPEN'`
// CascadeCancelToOpenProposal declines and requeues an OPEN proposal
// immediately when one of its participants cancels their own queue ticket
// directly, rather than leaving every other participant to wait out the
// full response window for something the system already knows can't happen
// -- ProposalExpireRequeueSQL would eventually rescue them anyway, but not
// for up to ProposalWindow's full duration for no reason. Must run inside
// the same transaction as the ticket cancel itself; a no-op if the ticket
// wasn't part of any currently-OPEN proposal.
func CascadeCancelToOpenProposal(ctx context.Context, tx *sql.Tx, ticketID, playerID string, now time.Time) error {
var proposalID string
err := tx.QueryRowContext(ctx, OpenProposalForCancelledTicketSQL, ticketID, playerID).Scan(&proposalID)
if err == sql.ErrNoRows {
return nil
}
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, ProposalDeclineSQL, proposalID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, ProposalDeclineRequeueSQL, proposalID, now.Add(domain.QueueExpiryWindow))
return err
}
const ProposalRecoverySelectSQL = `SELECT proposal_id, playlist, state, revision, expires_at
FROM proposals
WHERE proposal_id = $1