mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 19:03:43 +00:00
feat: atomically create matchmaking proposals
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func TestRetryableRecognisesPostgresSerializationAndDeadlockErrors(t *testing.T)
|
||||
}
|
||||
|
||||
func TestClaimSQLContainsDurableOwnershipFences(t *testing.T) {
|
||||
for _, fragment := range []string{"FOR UPDATE SKIP LOCKED", "state = 'QUEUED'", "proposal_participants", "revision = revision + 1"} {
|
||||
for _, fragment := range []string{"FOR UPDATE SKIP LOCKED", "state = 'QUEUED'", "proposal_participants", "revision = revision + 1", "INSERT INTO proposals"} {
|
||||
if !containsAnySQL(fragment) {
|
||||
t.Fatalf("claim boundary missing %q", fragment)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestClaimSQLContainsDurableOwnershipFences(t *testing.T) {
|
||||
}
|
||||
|
||||
func containsAnySQL(fragment string) bool {
|
||||
return index(CandidateClaimSQL, fragment) >= 0 || index(ProposalParticipantInsertSQL, fragment) >= 0 || index(QueueTicketProposeSQL, fragment) >= 0
|
||||
return index(CandidateClaimSQL, fragment) >= 0 || index(ProposalParticipantInsertSQL, fragment) >= 0 || index(QueueTicketProposeSQL, fragment) >= 0 || index(ProposalInsertSQL, fragment) >= 0
|
||||
}
|
||||
|
||||
func index(s, fragment string) int {
|
||||
|
||||
Reference in New Issue
Block a user