fix(multiplayer): fence client queue cancellation states

This commit is contained in:
Josh Creek
2026-09-03 00:15:41 +01:00
parent 1976c6eac6
commit a15368ed29
6 changed files with 75 additions and 6 deletions
+33 -2
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
@@ -42,8 +43,12 @@ RETURNING ticket_id, player_id, playlist, state, client_build, protocol_version,
QueueTicketCancelSQL = `UPDATE queue_tickets SET state = 'CANCELLED',
revision = revision + 1, expires_at = $4
WHERE ticket_id = $1 AND player_id = $2 AND revision = $3
AND state NOT IN ('COMPLETED', 'CANCELLED', 'EXPIRED', 'FAILED')
AND state IN ('QUEUED', 'PROPOSED')
RETURNING ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at, revision, predicted_rtt`
QueueMutationFailureSQL = `SELECT player_id, state, revision, expires_at
FROM queue_tickets
WHERE ticket_id = $1
FOR UPDATE`
QueueCooldownSelectSQL = `SELECT ends_at
FROM penalties
WHERE player_id = $1 AND playlist = $2
@@ -294,7 +299,10 @@ func mutateQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID, idem
var record queueTicketRecord
var predictedRTT []byte
if err := tx.QueryRowContext(ctx, mutationSQL, ticketID, playerID, expectedRevision, now).Scan(&record.TicketID, &record.PlayerID, &record.Playlist, &record.State, &record.ClientBuild, &record.ProtocolVersion, &record.EnqueuedAt, &record.ExpiresAt, &record.Revision, &predictedRTT); err != nil {
return fmt.Errorf("queue mutation rejected: %w", err)
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("queue mutation rejected: %w", err)
}
return classifyQueueMutationFailure(ctx, tx, playerID, ticketID, expectedRevision, now)
}
if err := json.Unmarshal(predictedRTT, &record.PredictedRTT); err != nil {
return fmt.Errorf("decode queue RTT: %w", err)
@@ -315,6 +323,29 @@ func mutateQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID, idem
return ticket, err
}
func classifyQueueMutationFailure(ctx context.Context, tx *sql.Tx, playerID, ticketID string, expectedRevision uint64, now time.Time) error {
var owner, state string
var revision uint64
var expiresAt time.Time
err := tx.QueryRowContext(ctx, QueueMutationFailureSQL, ticketID).Scan(&owner, &state, &revision, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrTicketNotFound
}
if err != nil {
return err
}
if owner != playerID {
return domain.ErrNotTicketOwner
}
if (state == string(domain.Queued) || state == string(domain.Proposed)) && !now.Before(expiresAt) {
return domain.ErrTicketExpired
}
if revision != expectedRevision {
return domain.ErrStaleRevision
}
return fmt.Errorf("%w: %s in %s", domain.ErrConflict, "queue mutation", state)
}
func queueTicketRecordFromDomain(ticket domain.QueueTicket) queueTicketRecord {
return queueTicketRecord{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, ProposalID: ticket.ProposalID, MatchID: ticket.MatchID, Playlist: string(ticket.Playlist), State: string(ticket.State), ClientBuild: ticket.Candidate.ClientBuild, ProtocolVersion: ticket.Candidate.ProtocolVersion, EnqueuedAt: ticket.EnqueuedAt, ExpiresAt: ticket.ExpiresAt, Revision: ticket.Revision, PredictedRTT: ticket.Candidate.PredictedRTT}
}