From 91658fbc13159eb777a469618ed0c47f66f61f05 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:47:00 +0100 Subject: [PATCH] fix(multiplayer): recover assignment handoff --- Game/scripts/control_plane_client.gd | 34 +++++++++++++++- Game/scripts/matchmaking_state.gd | 27 +++++++++++-- Game/tests/cases/test_control_plane_client.gd | 40 +++++++++++++++++++ Game/tests/cases/test_matchmaking_state.gd | 11 +++++ multiplayer-next.md | 2 + server/api/service.go | 3 +- server/api/service_test.go | 7 ++++ server/contracts/v1/openapi.json | 2 +- server/domain/queue.go | 1 + server/store/postgres_integration_test.go | 4 ++ server/store/queue_sql.go | 19 +++++---- server/store/queue_sql_test.go | 12 +++++- 12 files changed, 147 insertions(+), 15 deletions(-) diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index 6a57443f..f812f68d 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -493,7 +493,8 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head request_failed.emit(operation, response_code, "invalid proposal identifier") return if operation.begins_with("queue_"): - state.apply_ticket_update(normalize_ticket(payload)) + if state.apply_ticket_update(normalize_ticket(payload), operation == "queue_recover"): + _queue_assignment_if_ready(payload) elif operation.begins_with("proposal_"): state.apply_proposal_update(normalize_proposal(payload)) elif operation == "ranked_profile": @@ -518,6 +519,18 @@ func _handle_websocket_packet(packet: PackedByteArray) -> void: websocket_event.emit(event) var event_name := String(event["event"]) if event_name == "state_changed": + # Allocation and match lifecycle rows are keyed by match ID, not ticket + # ID. Recover the owner-scoped ticket projection instead of feeding the + # match revision/resource into the ticket reducer. ASSIGNMENT_READY also + # carries the durable lookup key, so the assignment fetch can follow the + # recovery request without depending on a circular assignment_changed + # notification from the assignment GET itself. + if event.has("match_id"): + var match_id := String(event["match_id"]) + _on_resync_required(state.ticket_id) + if String(event["state"]) == "ASSIGNMENT_READY": + _pending_assignment_match_id = match_id + return var update := event.duplicate(true) update["ticket_id"] = String(event["resource_id"]) if not state.apply_ticket_update(update): @@ -549,7 +562,11 @@ static func _valid_websocket_event(event: Dictionary) -> bool: if event_name == "error": return event.has("code") and String(event["code"]) in ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"] if event_name == "state_changed": - return event.has("state") and String(event["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"] + if not event.has("state") or String(event["state"]) not in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]: + return false + if event.has("match_id"): + return event["match_id"] is String and is_valid_resource_id(String(event["match_id"])) and String(event["match_id"]) == String(event["resource_id"]) + return true if event_name == "proposal_changed": return event.has("state") and String(event["state"]) in ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"] return false @@ -577,11 +594,24 @@ static func _valid_queue_response(payload: Dictionary) -> bool: return false if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]: return false + if payload.has("match_id"): + if not payload["match_id"] is String or not is_valid_resource_id(String(payload["match_id"])): + return false + if String(payload["state"]) not in ["ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "FAILED", "CANCELLED"]: + return false if not _valid_revision(payload["revision"]): return false return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"])) +func _queue_assignment_if_ready(payload: Dictionary) -> void: + if String(payload.get("state", "")) != "ASSIGNMENT_READY": + return + var match_id := String(payload.get("match_id", "")) + if is_valid_resource_id(match_id): + _pending_assignment_match_id = match_id + + static func _valid_proposal_response(payload: Dictionary) -> bool: if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array: return false diff --git a/Game/scripts/matchmaking_state.gd b/Game/scripts/matchmaking_state.gd index adf1152e..2868647b 100644 --- a/Game/scripts/matchmaking_state.gd +++ b/Game/scripts/matchmaking_state.gd @@ -48,7 +48,7 @@ func begin_queue(new_ticket_id: String, new_playlist: String) -> bool: return true -func apply_ticket_update(update: Dictionary) -> bool: +func apply_ticket_update(update: Dictionary, authoritative_snapshot: bool = false) -> bool: if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"): return _request_resync(self.ticket_id) if update.has("playlist") and not _valid_playlist(String(update["playlist"])): @@ -75,12 +75,15 @@ func apply_ticket_update(update: Dictionary) -> bool: if update.has("enqueued_at_unix"): enqueued_at_unix = maxi(0, int(update["enqueued_at_unix"])) return true - if incoming_revision > revision + 1: + if incoming_revision > revision + 1 and not authoritative_snapshot: return _request_resync(self.ticket_id) var incoming_state := String(update["state"]) if not _is_ticket_state(incoming_state): return _request_resync(self.ticket_id) - if not _is_legal_ticket_transition(phase, incoming_state): + if authoritative_snapshot: + if not _can_reach_ticket_state(phase, incoming_state): + return _request_resync(self.ticket_id) + elif not _is_legal_ticket_transition(phase, incoming_state): return _request_resync(self.ticket_id) revision = incoming_revision phase = incoming_state @@ -319,6 +322,24 @@ func _is_legal_ticket_transition(from: String, to: String) -> bool: return transitions.has(from) and to in transitions[from] +func _can_reach_ticket_state(from: String, to: String) -> bool: + if from == to: + return true + var pending: Array[String] = [from] + var visited := {} + visited[from] = true + while not pending.is_empty(): + var current: String = pending.pop_front() + for candidate in [QUEUED, PROPOSED, ACCEPTED, ALLOCATING, PROCESS_READY, ASSIGNMENT_READY, ASSIGNED, CONNECTING, LIVE, RESULT_PENDING, COMPLETED, CANCELLED, EXPIRED, FAILED]: + if visited.has(candidate) or not _is_legal_ticket_transition(current, candidate): + continue + if candidate == to: + return true + visited[candidate] = true + pending.append(candidate) + return false + + func _is_legal_proposal_transition(from: String, to: String) -> bool: if from.is_empty(): return to == "OPEN" diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 5bff50c2..9513429b 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -110,6 +110,37 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void var unsafe_resource := envelope.duplicate() unsafe_resource["resource_id"] = "ticket_123456789/secret" assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected") + var match_state := {"event": "state_changed", "revision": 4, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_1234567890"} + assert_true(ControlPlaneClient._valid_websocket_event(match_state), "match-scoped lifecycle event is accepted") + match_state["match_id"] = "different_match_123" + assert_true(not ControlPlaneClient._valid_websocket_event(match_state), "match lifecycle identity must equal its resource identity") + + +func test_match_assignment_ready_event_recovers_ticket_and_schedules_assignment_fetch() -> void: + var client := ControlPlaneClient.new() + client._ready() + assert_true(client.state.begin_queue("ticket_assignment_1", "casual"), "queue setup succeeds") + client._operation = "queue_heartbeat" + var event := {"event": "state_changed", "revision": 4, "resource_id": "match_assignment_1", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_assignment_1"} + client._handle_websocket_packet(JSON.stringify(event).to_utf8_buffer()) + assert_eq(client._pending_resync_resource_id, "ticket_assignment_1", "match event requests authoritative ticket recovery") + assert_eq(client._pending_assignment_match_id, "match_assignment_1", "assignment lookup no longer depends on a prior assignment GET") + assert_eq(client.state.ticket_id, "ticket_assignment_1", "match resource is never projected as a ticket identity") + client.free() + + +func test_recovered_assignment_ready_ticket_schedules_fetch_after_missed_revisions() -> void: + var client := ControlPlaneClient.new() + client._ready() + client.player_id = "player_1234567890" + client.state.begin_queue("ticket_assignment_1", "casual") + assert_true(client.state.apply_ticket_update({"ticket_id": "ticket_assignment_1", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "proposal setup applies") + client._operation = "queue_recover" + var recovered := {"ticket_id": "ticket_assignment_1", "player_id": "player_1234567890", "match_id": "match_assignment_1", "playlist": "casual", "state": "ASSIGNMENT_READY", "revision": 5, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"} + client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(recovered).to_utf8_buffer()) + assert_eq(client.state.phase, MatchmakingState.ASSIGNMENT_READY, "REST recovery applies a forward authoritative snapshot") + assert_eq(client._pending_assignment_match_id, "match_assignment_1", "recovered snapshot supplies the assignment lookup key") + client.free() func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void: @@ -188,6 +219,15 @@ func test_queue_response_requires_the_complete_contract_shape() -> void: var malformed_player := valid.duplicate() malformed_player["player_id"] = "player/unsafe" assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected") + var assigned := valid.duplicate() + assigned["state"] = "ASSIGNMENT_READY" + assigned["match_id"] = "match_1234567890" + assert_true(ControlPlaneClient._valid_queue_response(assigned), "recovered assignment-ready ticket carries its match lookup identity") + var premature_match := valid.duplicate() + premature_match["match_id"] = "match_1234567890" + assert_true(not ControlPlaneClient._valid_queue_response(premature_match), "pre-match ticket cannot smuggle a match identity") + assigned["match_id"] = "match/unsafe" + assert_true(not ControlPlaneClient._valid_queue_response(assigned), "unsafe recovered match identity is rejected") func test_proposal_response_requires_structured_unique_participants() -> void: diff --git a/Game/tests/cases/test_matchmaking_state.gd b/Game/tests/cases/test_matchmaking_state.gd index 858cf231..9c755561 100644 --- a/Game/tests/cases/test_matchmaking_state.gd +++ b/Game/tests/cases/test_matchmaking_state.gd @@ -75,6 +75,17 @@ func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() -> assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase") +func test_authoritative_ticket_snapshot_can_cross_missed_forward_revisions_but_not_rewind() -> void: + var state := MatchmakingState.new() + state.begin_queue("ticket-snapshot", "casual") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "incremental proposal applies") + assert_true(state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 5, "state": "ASSIGNMENT_READY", "playlist": "casual"}, true), "owner-scoped REST snapshot crosses missed forward states") + assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "authoritative recovery reaches assignment readiness") + state.needs_resync = false + assert_true(not state.apply_ticket_update({"ticket_id": "ticket-snapshot", "revision": 6, "state": "QUEUED", "playlist": "casual"}, true), "authoritative snapshot cannot rewind an assigned match") + assert_eq(state.phase, MatchmakingState.ASSIGNMENT_READY, "rejected snapshot cannot mutate phase") + + func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void: var state := MatchmakingState.new() state.begin_queue("ticket-revision", "casual") diff --git a/multiplayer-next.md b/multiplayer-next.md index a3bd3d6b..e2f04a26 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1630,3 +1630,5 @@ Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives The audio TODO now has a runtime foundation: `AudioManager` generates bounded placeholder tones for kickoff countdowns, camera-reported ball impacts, static-wall contacts, goals, UI clicks, a local thrust/turbo-pitched engine loop, and a rising-edge turbo cue without adding binary assets; authored sound design and production audio QA remain open. The video-settings TODO is likewise locally implemented: presets, vsync, refresh-derived FPS caps, and resolution scaling are wired through `VideoSettings` and the settings menu. The remaining acceptance work is low/mid-tier hardware frame-time and image-quality profiling, which cannot be certified from this workspace. + +Assignment handoff now has a non-circular recovery path. Match-scoped lifecycle events are no longer misapplied as queue-ticket resources: they trigger owner-scoped ticket recovery, and recovered active tickets include their durable `match_id`. An `ASSIGNMENT_READY` event or recovered ticket can therefore drive `GET /assignments/{matchId}` without already having fetched that assignment. Owner-scoped REST ticket snapshots may cross missed revisions only along a reachable forward lifecycle path, while incremental WebSocket updates remain strictly contiguous and neither path can rewind state. The OpenAPI queue projection includes the optional active match identity, Go tests cover the store/API projection, and the 199-test Godot harness covers match-resource separation, malformed identities, missed-revision recovery, illegal rewinds, and assignment-fetch scheduling. The real PostgreSQL assertion is committed with the store integration suite; rerunning it in this workspace is temporarily blocked by Docker storage exhaustion (`initdb` cannot create `pg_wal`), so live SQL evidence remains open rather than being claimed from the static/unit gates. diff --git a/server/api/service.go b/server/api/service.go index d0aa5b01..500c43e0 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -349,6 +349,7 @@ type queueCreateRequest struct { type queueResponse struct { TicketID string `json:"ticket_id"` PlayerID string `json:"player_id"` + MatchID string `json:"match_id,omitempty"` State string `json:"state"` Revision uint64 `json:"revision"` EnqueuedAt time.Time `json:"enqueued_at"` @@ -1116,7 +1117,7 @@ func decodeBody(w http.ResponseWriter, r *http.Request, target any) bool { } func toQueueResponse(ticket domain.QueueTicket) queueResponse { - return queueResponse{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, Playlist: string(ticket.Playlist), State: string(ticket.State), Revision: ticket.Revision, EnqueuedAt: ticket.EnqueuedAt, ExpiresAt: ticket.ExpiresAt} + return queueResponse{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, MatchID: ticket.MatchID, Playlist: string(ticket.Playlist), State: string(ticket.State), Revision: ticket.Revision, EnqueuedAt: ticket.EnqueuedAt, ExpiresAt: ticket.ExpiresAt} } func toProposalResponse(proposal domain.Proposal) proposalResponse { diff --git a/server/api/service_test.go b/server/api/service_test.go index 97af83b3..885f0e21 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -22,6 +22,13 @@ import ( type queueBackendSpy struct{ createCalls, heartbeatCalls, cancelCalls, getCalls int } +func TestQueueResponseCarriesRecoveredMatchIdentity(t *testing.T) { + response := toQueueResponse(domain.QueueTicket{TicketID: "ticket-1234567890", PlayerID: "player-1234567890", MatchID: "match-1234567890", State: domain.AssignmentReady}) + if response.MatchID != "match-1234567890" { + t.Fatalf("queue response match ID = %q", response.MatchID) + } +} + type candidateIndexSpy struct { upsertCalls, removeCalls int upsertErr, removeErr error diff --git a/server/contracts/v1/openapi.json b/server/contracts/v1/openapi.json index d1c4ad37..fc2350a9 100644 --- a/server/contracts/v1/openapi.json +++ b/server/contracts/v1/openapi.json @@ -86,7 +86,7 @@ "Profile": {"type": "object", "required": ["player_id", "rating", "rd", "provisional"], "additionalProperties": false, "properties": {"player_id": {"$ref": "#/components/schemas/OpaqueId"}, "rating": {"type": "number"}, "rd": {"type": "number"}, "provisional": {"type": "boolean"}}}, "RankedProfile": {"type": "object", "required": ["rating", "rd", "volatility", "ranked_games", "tier", "provisional"], "additionalProperties": false, "properties": {"rating": {"type": "number", "minimum": 0}, "rd": {"type": "number", "minimum": 0}, "volatility": {"type": "number", "minimum": 0}, "ranked_games": {"type": "integer", "minimum": 0}, "tier": {"type": "string", "enum": ["PROVISIONAL", "BRONZE", "SILVER", "GOLD", "PLATINUM", "DIAMOND"]}, "provisional": {"type": "boolean"}, "season_id": {"$ref": "#/components/schemas/OpaqueId"}, "season_ends_at": {"type": "string", "format": "date-time"}}}, "QueueCreate": {"type": "object", "required": ["playlist", "client_build", "protocol_version"], "additionalProperties": false, "properties": {"playlist": {"type": "string", "enum": ["casual", "ranked"]}, "client_build": {"type": "string", "minLength": 1, "maxLength": 128}, "protocol_version": {"type": "integer", "minimum": 1}}}, - "QueueTicket": {"type": "object", "required": ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"], "additionalProperties": false, "properties": {"ticket_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "playlist": {"type": "string", "enum": ["casual", "ranked"]}, "state": {"$ref": "#/components/schemas/QueueState"}, "revision": {"type": "integer", "minimum": 0}, "enqueued_at": {"type": "string", "format": "date-time"}, "expires_at": {"type": "string", "format": "date-time"}}}, + "QueueTicket": {"type": "object", "required": ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"], "additionalProperties": false, "properties": {"ticket_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "match_id": {"$ref": "#/components/schemas/OpaqueId"}, "playlist": {"type": "string", "enum": ["casual", "ranked"]}, "state": {"$ref": "#/components/schemas/QueueState"}, "revision": {"type": "integer", "minimum": 0}, "enqueued_at": {"type": "string", "format": "date-time"}, "expires_at": {"type": "string", "format": "date-time"}}}, "QueueState": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]}, "Proposal": {"type": "object", "required": ["proposal_id", "revision", "state", "expires_at", "participants"], "additionalProperties": false, "properties": {"proposal_id": {"$ref": "#/components/schemas/OpaqueId"}, "revision": {"type": "integer", "minimum": 0}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}, "expires_at": {"type": "string", "format": "date-time"}, "participants": {"type": "array", "minItems": 2, "maxItems": 6, "items": {"$ref": "#/components/schemas/ProposalParticipant"}}}}, "ProposalParticipant": {"type": "object", "required": ["player_id", "response", "team", "slot"], "additionalProperties": false, "properties": {"player_id": {"$ref": "#/components/schemas/OpaqueId"}, "response": {"type": "string", "enum": ["PENDING", "ACCEPTED", "DECLINED", "TIMED_OUT"]}, "team": {"type": "integer", "minimum": 0, "maximum": 1}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}}}, diff --git a/server/domain/queue.go b/server/domain/queue.go index 59f4167d..3c9a5107 100644 --- a/server/domain/queue.go +++ b/server/domain/queue.go @@ -26,6 +26,7 @@ var ( type QueueTicket struct { TicketID string PlayerID string + MatchID string Candidate Candidate Playlist Playlist State State diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index 1d529c9c..9ab7feee 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -306,6 +306,10 @@ func TestPostgreSQLAllocationMatchClaimLeaseAndBindFence(t *testing.T) { if err := BindAllocatedMatch(ctx, db, allocation); err != nil { t.Fatalf("bind allocation: %v", err) } + recoveredTicket, err := GetQueueTicket(ctx, db, "allocation-match-a", "allocation-match-ticket-0", now.Add(2*time.Second)) + if err != nil || recoveredTicket.MatchID != "allocation-match" { + t.Fatalf("recovered ticket match=%q err=%v", recoveredTicket.MatchID, err) + } var allocatingTickets int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM queue_tickets WHERE ticket_id LIKE 'allocation-match-ticket-%' AND state = 'ALLOCATING'`).Scan(&allocatingTickets); err != nil || allocatingTickets != 2 { t.Fatalf("allocating tickets=%d err=%v", allocatingTickets, err) diff --git a/server/store/queue_sql.go b/server/store/queue_sql.go index ecab80f0..e28252e0 100644 --- a/server/store/queue_sql.go +++ b/server/store/queue_sql.go @@ -21,10 +21,14 @@ ON CONFLICT (scope, idempotency_key) DO NOTHING` FROM idempotency_keys WHERE scope = $1 AND idempotency_key = $2 FOR UPDATE` - QueueTicketSelectSQL = `SELECT ticket_id, player_id, playlist, state, client_build, - protocol_version, enqueued_at, expires_at, revision, predicted_rtt -FROM queue_tickets -WHERE ticket_id = $1 AND player_id = $2` + QueueTicketSelectSQL = `SELECT q.ticket_id, q.player_id, q.playlist, q.state, q.client_build, + q.protocol_version, q.enqueued_at, q.expires_at, q.revision, q.predicted_rtt, + COALESCE((SELECT mp.match_id FROM match_participants mp + WHERE mp.ticket_id = q.ticket_id AND mp.player_id = q.player_id + AND mp.participation_active + LIMIT 1), '') +FROM queue_tickets q +WHERE q.ticket_id = $1 AND q.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 @@ -173,6 +177,7 @@ func CreateQueueTicket(ctx context.Context, db *sql.DB, ticketID, playerID, idem type queueTicketRecord struct { TicketID string `json:"ticket_id"` PlayerID string `json:"player_id"` + MatchID string `json:"match_id,omitempty"` Playlist string `json:"playlist"` State string `json:"state"` ClientBuild string `json:"client_build"` @@ -230,7 +235,7 @@ func GetQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID string, } var record queueTicketRecord var predictedRTT []byte - if err := db.QueryRowContext(ctx, QueueTicketSelectSQL, ticketID, playerID).Scan(&record.TicketID, &record.PlayerID, &record.Playlist, &record.State, &record.ClientBuild, &record.ProtocolVersion, &record.EnqueuedAt, &record.ExpiresAt, &record.Revision, &predictedRTT); err != nil { + if err := db.QueryRowContext(ctx, QueueTicketSelectSQL, ticketID, playerID).Scan(&record.TicketID, &record.PlayerID, &record.Playlist, &record.State, &record.ClientBuild, &record.ProtocolVersion, &record.EnqueuedAt, &record.ExpiresAt, &record.Revision, &predictedRTT, &record.MatchID); err != nil { return domain.QueueTicket{}, err } if err := json.Unmarshal(predictedRTT, &record.PredictedRTT); err != nil { @@ -305,9 +310,9 @@ func mutateQueueTicket(ctx context.Context, db *sql.DB, playerID, ticketID, idem } func queueTicketRecordFromDomain(ticket domain.QueueTicket) queueTicketRecord { - return queueTicketRecord{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, 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} + return queueTicketRecord{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, 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} } func queueTicketRecordToDomain(record queueTicketRecord) domain.QueueTicket { candidate := domain.Candidate{TicketID: record.TicketID, PlayerID: record.PlayerID, Playlist: domain.Playlist(record.Playlist), ClientBuild: record.ClientBuild, ProtocolVersion: record.ProtocolVersion, EnqueuedAt: record.EnqueuedAt, PredictedRTT: record.PredictedRTT} - return domain.QueueTicket{TicketID: record.TicketID, PlayerID: record.PlayerID, Candidate: candidate, Playlist: domain.Playlist(record.Playlist), State: domain.State(record.State), Revision: record.Revision, EnqueuedAt: record.EnqueuedAt, ExpiresAt: record.ExpiresAt} + return domain.QueueTicket{TicketID: record.TicketID, PlayerID: record.PlayerID, MatchID: record.MatchID, Candidate: candidate, Playlist: domain.Playlist(record.Playlist), State: domain.State(record.State), Revision: record.Revision, EnqueuedAt: record.EnqueuedAt, ExpiresAt: record.ExpiresAt} } diff --git a/server/store/queue_sql_test.go b/server/store/queue_sql_test.go index 1016442c..ece663e8 100644 --- a/server/store/queue_sql_test.go +++ b/server/store/queue_sql_test.go @@ -10,7 +10,7 @@ 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"}, + QueueTicketSelectSQL: {"q.ticket_id = $1", "q.player_id = $2", "match_participants", "participation_active"}, 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"}, @@ -27,6 +27,16 @@ func TestQueueSQLUsesDurableIdempotencyAndOwnerScopedRecovery(t *testing.T) { } } +func TestQueueTicketRecordPreservesRecoveredMatchIdentity(t *testing.T) { + ticket := queueTicketRecordToDomain(queueTicketRecord{TicketID: "ticket-1", PlayerID: "player-1", MatchID: "match-1", Playlist: string(domain.Casual), State: string(domain.AssignmentReady)}) + if ticket.MatchID != "match-1" { + t.Fatalf("recovered match ID = %q", ticket.MatchID) + } + if got := queueTicketRecordFromDomain(ticket).MatchID; got != "match-1" { + t.Fatalf("stored match ID = %q", got) + } +} + func TestLoadRankedParticipantsRejectsNonSixPlayerLookupsWithoutDatabase(t *testing.T) { if _, err := LoadRankedParticipants(nil, nil, []string{"player-1"}); err == nil { t.Fatal("partial ranked identity lookup was accepted")