diff --git a/multiplayer-todo.md b/multiplayer-todo.md index b41e988a..ecdf3f07 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1191,7 +1191,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go`, `server/store/queue_sql.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, candidate/player ownership binding, owner-scoped SQL recovery, expired recovery as a terminal error, server-owned candidate resolution, strict compatibility metadata and cache loss/atomic rebuild; live PostgreSQL row execution, real Redis index/TTLs and restart/failover integration remain | +| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter with playlist/build/protocol compatibility metadata | `server/domain/queue.go`, `server/store/candidates.go`, `server/store/queue_sql.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, candidate/player ownership binding, owner/revision-scoped SQL heartbeat/cancel/recovery, expired recovery as a terminal error, server-owned candidate resolution, strict compatibility metadata and cache loss/atomic rebuild; live PostgreSQL row execution, real Redis index/TTLs and restart/failover integration remain | | 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, fences duplicate player identities and rejects playlist/build/protocol mixing | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance, malformed candidates, duplicate identities, compatibility mismatches 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 | diff --git a/server/store/queue_sql.go b/server/store/queue_sql.go index 4ab1a813..f74fccf4 100644 --- a/server/store/queue_sql.go +++ b/server/store/queue_sql.go @@ -25,6 +25,16 @@ FOR UPDATE` protocol_version, enqueued_at, expires_at, revision FROM queue_tickets WHERE ticket_id = $1 AND player_id = $2` + QueueTicketHeartbeatSQL = `UPDATE queue_tickets SET revision = revision + 1, + expires_at = $4 + INTERVAL '30 seconds' +WHERE ticket_id = $1 AND player_id = $2 AND revision = $3 + AND state IN ('QUEUED', 'PROPOSED') AND expires_at > $4 +RETURNING ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at, revision` + 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') +RETURNING ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at, revision` ) func CreateQueueTicket(ctx context.Context, db *sql.DB, ticketID, playerID, idempotencyKey string, spec domain.QueueSpec, now time.Time) (domain.QueueTicket, error) { @@ -92,6 +102,58 @@ func GetQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID string) return queueTicketRecordToDomain(record), nil } +func HeartbeatQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID, idempotencyKey string, expectedRevision uint64, now time.Time) (domain.QueueTicket, error) { + return mutateQueueTicket(ctx, db, playerID, ticketID, idempotencyKey, expectedRevision, now, "heartbeat", QueueTicketHeartbeatSQL) +} + +func CancelQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID, idempotencyKey string, expectedRevision uint64, now time.Time) (domain.QueueTicket, error) { + return mutateQueueTicket(ctx, db, playerID, ticketID, idempotencyKey, expectedRevision, now, "cancel", QueueTicketCancelSQL) +} + +func mutateQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID, idempotencyKey string, expectedRevision uint64, now time.Time, operation, mutationSQL string) (ticket domain.QueueTicket, err error) { + if db == nil || playerID == "" || ticketID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() || (operation != "heartbeat" && operation != "cancel") { + return domain.QueueTicket{}, fmt.Errorf("invalid queue mutation arguments") + } + digest := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s|%d", operation, playerID, ticketID, expectedRevision))) + err = RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { + result, err := tx.ExecContext(ctx, QueueIdempotencyInsertSQL, QueueIdempotencyScope+"."+operation, idempotencyKey, digest[:], []byte("{}")) + if err != nil { + return err + } + inserted, err := result.RowsAffected() + if err != nil { + return err + } + if inserted == 0 { + var priorDigest, priorResult []byte + if err := tx.QueryRowContext(ctx, QueueIdempotencySelectSQL, QueueIdempotencyScope+"."+operation, idempotencyKey).Scan(&priorDigest, &priorResult); err != nil { + return err + } + if !bytes.Equal(priorDigest, digest[:]) { + return fmt.Errorf("queue mutation idempotency conflict") + } + var prior queueTicketRecord + if err := json.Unmarshal(priorResult, &prior); err != nil { + return fmt.Errorf("invalid stored queue result: %w", err) + } + ticket = queueTicketRecordToDomain(prior) + return nil + } + var record queueTicketRecord + 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); err != nil { + return fmt.Errorf("queue mutation rejected: %w", err) + } + ticket = queueTicketRecordToDomain(record) + stored, err := json.Marshal(record) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE idempotency_keys SET result = $3 WHERE scope = $1 AND idempotency_key = $2`, QueueIdempotencyScope+"."+operation, idempotencyKey, stored) + return err + }) + return ticket, err +} + func queueTicketRecordFromDomain(ticket domain.QueueTicket) queueTicketRecord { return queueTicketRecord{ticket.TicketID, ticket.PlayerID, string(ticket.Playlist), string(ticket.State), ticket.Candidate.ClientBuild, ticket.Candidate.ProtocolVersion, ticket.EnqueuedAt, ticket.ExpiresAt, ticket.Revision} } diff --git a/server/store/queue_sql_test.go b/server/store/queue_sql_test.go index 5b4c7455..fcac0685 100644 --- a/server/store/queue_sql_test.go +++ b/server/store/queue_sql_test.go @@ -10,7 +10,10 @@ func TestQueueSQLUsesDurableIdempotencyAndOwnerScopedRecovery(t *testing.T) { for query, fragments := range map[string][]string{ QueueIdempotencyInsertSQL: {"idempotency_keys", "ON CONFLICT (scope, idempotency_key) DO NOTHING", "payload_digest"}, QueueIdempotencySelectSQL: {"scope = $1", "idempotency_key = $2", "FOR UPDATE"}, - QueueTicketSelectSQL: {"ticket_id = $1", "player_id = $2"}, QueueTicketInsertSQL: {"player_id", "playlist", "client_build", "protocol_version"}, + QueueTicketSelectSQL: {"ticket_id = $1", "player_id = $2"}, + QueueTicketInsertSQL: {"player_id", "playlist", "client_build", "protocol_version"}, + QueueTicketHeartbeatSQL: {"player_id = $2", "revision = $3", "expires_at > $4", "RETURNING"}, + QueueTicketCancelSQL: {"player_id = $2", "revision = $3", "state NOT IN", "RETURNING"}, } { for _, fragment := range fragments { if !contains(query, fragment) { @@ -20,6 +23,16 @@ func TestQueueSQLUsesDurableIdempotencyAndOwnerScopedRecovery(t *testing.T) { } } +func TestQueueMutationAdaptersRejectInvalidArgumentsWithoutDatabase(t *testing.T) { + now := time.Unix(1000, 0) + if _, err := HeartbeatQueueTicket(nil, nil, "player-1", "ticket-1", "short", 0, now); err == nil { + t.Fatal("invalid heartbeat accepted") + } + if _, err := CancelQueueTicket(nil, nil, "player-1", "ticket-1", "short", 0, now); err == nil { + t.Fatal("invalid cancel accepted") + } +} + func TestCreateQueueTicketRejectsInvalidArgumentsWithoutDatabase(t *testing.T) { if _, err := CreateQueueTicket(nil, nil, "ticket-1", "player-1", "short", domain.QueueSpec{Playlist: domain.Casual, ClientBuild: "build-1", ProtocolVersion: 1}, time.Unix(1000, 0)); err == nil { t.Fatal("invalid arguments accepted")