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
+4 -1
View File
@@ -88,7 +88,10 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
and ranked identity/arena metadata before creating proposal state; durable
queue precedence and allocation integration remain.
- [ ] **IN PROGRESS:** Fence proposals/participants in a PostgreSQL serializable transaction;
prove loss of an acknowledged Redis write cannot split players.
prove loss of an acknowledged Redis write cannot split players. The Go store
adapter now performs proposal insertion, participant insertion, and every
queue-ticket promotion in one rollback-safe SERIALIZABLE callback; live DB/
Redis failover testing remains.
- [ ] Casual: target 3v3 humans, after 60 s allow >=2 humans (one/team) plus
bots, kickoff-only human backfill and no backfill loss/decline penalty.
- [ ] **IN PROGRESS:** Ranked: exactly six humans, solo-only, no bots/backfill,
+1 -1
View File
@@ -1195,7 +1195,7 @@ the local/CI/community transport, not a silent production fallback.
| 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release; authenticated HTTP now accepts only opaque location/nonce input through a server-owned probe provider | `server/domain/probes.go`, adversarial fixtures and `server/api/service.go` cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine, five-clean release, authenticated provider arguments and rejection of client RTT fields; Steam coordinator and regional probe adapters remain |
| 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning; queue-backed formation now consumes the server-owned projection and fences duplicate player identities | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance, malformed candidates, duplicate identities and queue-backed oldest-anchor formation; full population fixtures and durable matcher claim integration remain |
| 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation; authenticated API exposes revisioned accept/decline mutations; formed matches now pass through a playlist-aware proposal boundary | `server/domain/proposal.go`, `formation.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision, casual lineup preparation and ranked metadata validation; queue precedence and allocation integration remain |
| 8.18 `[D:8.5,8.14,8.17]` | **IN PROGRESS.** Go store layer defines PostgreSQL SERIALIZABLE whole-transaction retries and queue candidate/proposal claim SQL using `FOR UPDATE SKIP LOCKED` plus durable uniqueness/revision fences | `server/store/serializable.go` and tests cover retry classification and claim-boundary invariants; live PostgreSQL adapter/row decoding, Redis candidate index/repair, worker-failure and concurrent two-matcher integration tests remain |
| 8.18 `[D:8.5,8.14,8.17]` | **IN PROGRESS.** Go store layer defines PostgreSQL SERIALIZABLE whole-transaction retries and queue candidate/proposal claim SQL using `FOR UPDATE SKIP LOCKED` plus durable uniqueness/revision fences; proposal creation now inserts proposal/participants and promotes every ticket in one rollback-safe transaction | `server/store/serializable.go`, `proposal_sql.go` and tests cover retry classification, claim-boundary invariants, zero-row claim aborts and atomic statement ordering; live PostgreSQL adapter/row decoding, Redis candidate index/repair, worker-failure and concurrent two-matcher integration tests remain |
| 8.19 `[D:8.18]` | **IN PROGRESS.** Pure Go casual lineup requires 26 humans with at least one per team, fills missing slots with explicit bots, permits kickoff-only bot-slot backfill and assigns no backfill penalty/rating update; proposal preparation now derives the lineup from formed teams | `server/domain/casual.go`, `formation.go` cover both-team minimum, bot shape, live-play rejection, zero-penalty backfill and casual proposal composition; queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties and live integration remain |
| 8.20 `[D:8.18]` | **IN PROGRESS.** Pure Go ranked admission requires six unique verified solo humans, rejects bots/backfill/parties, and allows only random-enabled non-elevated arenas; proposal preparation requires matching metadata for every formed player | `server/domain/ranked.go`, `formation.go` cover count, identity, party, bot/backfill, arena eligibility and formed-player metadata rejection; `ArenaRegistry` integration, allocation wiring and innocent-ticket restoration remain |
| 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input plus draw/OT/abandon fixtures; PostgreSQL snapshot locking, rating transaction integration, seasons and concurrent result transaction tests remain |
+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
})
}
+2 -2
View File
@@ -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 {