mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
fix(multiplayer): converge events through REST
This commit is contained in:
@@ -13,8 +13,10 @@ import (
|
||||
// RunProposalOutboxDispatcher delivers committed proposal changes to the
|
||||
// authenticated WebSocket subscribers. It only reads proposal_changed rows;
|
||||
// result and other outbox event types remain owned by their own consumers.
|
||||
// Delivery is at-least-once because the row is acknowledged only after every
|
||||
// participant publication succeeds.
|
||||
// The outbox guarantees after-commit publication into this replica's bounded
|
||||
// transient hub; WebSocket receipt is deliberately best-effort. Clients use
|
||||
// owner-scoped periodic REST recovery for correctness across disconnects and
|
||||
// replicas, so a socket notification is only a latency optimization.
|
||||
func RunProposalOutboxDispatcher(ctx context.Context, db *sql.DB, service *Service) {
|
||||
if db == nil || service == nil {
|
||||
return
|
||||
|
||||
@@ -349,6 +349,7 @@ type queueCreateRequest struct {
|
||||
type queueResponse struct {
|
||||
TicketID string `json:"ticket_id"`
|
||||
PlayerID string `json:"player_id"`
|
||||
ProposalID string `json:"proposal_id,omitempty"`
|
||||
MatchID string `json:"match_id,omitempty"`
|
||||
State string `json:"state"`
|
||||
Revision uint64 `json:"revision"`
|
||||
@@ -1117,7 +1118,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, MatchID: ticket.MatchID, 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, ProposalID: ticket.ProposalID, 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 {
|
||||
|
||||
@@ -23,7 +23,10 @@ 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})
|
||||
response := toQueueResponse(domain.QueueTicket{TicketID: "ticket-1234567890", PlayerID: "player-1234567890", ProposalID: "proposal-1234567890", MatchID: "match-1234567890", State: domain.AssignmentReady})
|
||||
if response.ProposalID != "proposal-1234567890" {
|
||||
t.Fatalf("queue response proposal ID = %q", response.ProposalID)
|
||||
}
|
||||
if response.MatchID != "match-1234567890" {
|
||||
t.Fatalf("queue response match ID = %q", response.MatchID)
|
||||
}
|
||||
|
||||
@@ -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"}, "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"}}},
|
||||
"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"}, "proposal_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}}},
|
||||
|
||||
@@ -26,6 +26,7 @@ var (
|
||||
type QueueTicket struct {
|
||||
TicketID string
|
||||
PlayerID string
|
||||
ProposalID string
|
||||
MatchID string
|
||||
Candidate Candidate
|
||||
Playlist Playlist
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
)
|
||||
|
||||
// OutboxEvent is the durable hand-off between a committed domain mutation and
|
||||
// transient WebSocket delivery. Consumers must make delivery idempotent by
|
||||
// event ID and only acknowledge after successful fan-out.
|
||||
// transient WebSocket publication. Consumers must make publication idempotent
|
||||
// by event ID and only acknowledge after the local adapter accepts the event.
|
||||
// Subscriber receipt is not durable; clients converge through REST recovery.
|
||||
type OutboxEvent struct {
|
||||
EventID string
|
||||
AggregateType string
|
||||
@@ -64,8 +65,9 @@ type OutboxDelivery func(context.Context, OutboxEvent) error
|
||||
|
||||
// OutboxDispatcher is the durable-to-transient bridge. Read and Ack are
|
||||
// injectable so ordering can be tested without a live PostgreSQL instance.
|
||||
// Delivery is at-least-once: a crash after delivery and before acknowledgement
|
||||
// leaves the event replayable, while a delivery failure stops the batch.
|
||||
// Adapter invocation is at-least-once: a crash after invocation and before
|
||||
// acknowledgement leaves the event replayable, while an adapter failure stops
|
||||
// the batch. This does not imply that a transient subscriber received it.
|
||||
type OutboxDispatcher struct {
|
||||
Read func(context.Context, int) ([]OutboxEvent, error)
|
||||
Ack func(context.Context, string, time.Time) error
|
||||
|
||||
@@ -520,6 +520,10 @@ func TestPostgreSQLProposalClaimAndResponseAreAtomic(t *testing.T) {
|
||||
if err := db.QueryRow(`SELECT count(*) FROM queue_tickets WHERE state = 'PROPOSED'`).Scan(&proposed); err != nil || proposed != 2 {
|
||||
t.Fatalf("proposed queue tickets = %d, err = %v", proposed, err)
|
||||
}
|
||||
recoveredTicket, err := GetQueueTicket(ctx, db, "proposal-player-a", "proposal-ticket-0", now)
|
||||
if err != nil || recoveredTicket.ProposalID != proposal.ProposalID {
|
||||
t.Fatalf("recovered ticket proposal=%q err=%v", recoveredTicket.ProposalID, err)
|
||||
}
|
||||
recovered, err := GetProposal(ctx, db, "proposal-player-a", proposal.ProposalID, now)
|
||||
if err != nil {
|
||||
t.Fatalf("recover proposal: %v", err)
|
||||
|
||||
@@ -23,6 +23,11 @@ WHERE scope = $1 AND idempotency_key = $2
|
||||
FOR UPDATE`
|
||||
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 pp.proposal_id FROM proposal_participants pp
|
||||
JOIN proposals p ON p.proposal_id = pp.proposal_id
|
||||
WHERE pp.ticket_id = q.ticket_id AND pp.player_id = q.player_id
|
||||
AND p.state = 'OPEN'
|
||||
LIMIT 1), ''),
|
||||
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
|
||||
@@ -177,6 +182,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"`
|
||||
ProposalID string `json:"proposal_id,omitempty"`
|
||||
MatchID string `json:"match_id,omitempty"`
|
||||
Playlist string `json:"playlist"`
|
||||
State string `json:"state"`
|
||||
@@ -235,7 +241,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, &record.MatchID); 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.ProposalID, &record.MatchID); err != nil {
|
||||
return domain.QueueTicket{}, err
|
||||
}
|
||||
if err := json.Unmarshal(predictedRTT, &record.PredictedRTT); err != nil {
|
||||
@@ -310,9 +316,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, 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}
|
||||
return queueTicketRecord{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, ProposalID: ticket.ProposalID, 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, MatchID: record.MatchID, 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, ProposalID: record.ProposalID, MatchID: record.MatchID, Candidate: candidate, Playlist: domain.Playlist(record.Playlist), State: domain.State(record.State), Revision: record.Revision, EnqueuedAt: record.EnqueuedAt, ExpiresAt: record.ExpiresAt}
|
||||
}
|
||||
|
||||
@@ -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: {"q.ticket_id = $1", "q.player_id = $2", "match_participants", "participation_active"},
|
||||
QueueTicketSelectSQL: {"q.ticket_id = $1", "q.player_id = $2", "proposal_participants", "p.state = 'OPEN'", "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"},
|
||||
@@ -28,13 +28,19 @@ 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)})
|
||||
ticket := queueTicketRecordToDomain(queueTicketRecord{TicketID: "ticket-1", PlayerID: "player-1", ProposalID: "proposal-1", MatchID: "match-1", Playlist: string(domain.Casual), State: string(domain.AssignmentReady)})
|
||||
if ticket.ProposalID != "proposal-1" {
|
||||
t.Fatalf("recovered proposal ID = %q", ticket.ProposalID)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if got := queueTicketRecordFromDomain(ticket).ProposalID; got != "proposal-1" {
|
||||
t.Fatalf("stored proposal ID = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRankedParticipantsRejectsNonSixPlayerLookupsWithoutDatabase(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user