feat: atomically create matchmaking proposals

This commit is contained in:
Josh Creek
2026-08-31 21:40:21 +01:00
parent 229ded8613
commit bf4da9fd39
4 changed files with 56 additions and 4 deletions
+49
View File
@@ -0,0 +1,49 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
const ProposalInsertSQL = `INSERT INTO proposals
(proposal_id, playlist, state, expires_at, revision)
VALUES ($1, $2, 'OPEN', $3, 0)`
// CreateProposal atomically claims the queue tickets and creates the proposal.
// Every statement runs inside the same SERIALIZABLE retry callback; callers
// must never publish a proposal from a cache-only candidate list.
func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, ticketIDs map[string]string, now time.Time) error {
if proposal.ProposalID == "" || len(proposal.Participants) == 0 {
return fmt.Errorf("invalid proposal transaction")
}
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, ProposalInsertSQL, proposal.ProposalID, proposal.Playlist, proposal.ExpiresAt); err != nil {
return err
}
for _, participant := range proposal.Participants {
ticketID := ticketIDs[participant.PlayerID]
if participant.PlayerID == "" || ticketID == "" {
return fmt.Errorf("missing proposal ticket mapping")
}
if _, err := tx.ExecContext(ctx, ProposalParticipantInsertSQL, proposal.ProposalID, participant.PlayerID, ticketID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, QueueTicketProposeSQL, ticketID, now)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return fmt.Errorf("queue ticket claim lost")
}
}
return nil
})
}