mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): persist connection generation leases
This commit is contained in:
+38
-10
@@ -44,7 +44,8 @@ type ServerShutdowner interface {
|
||||
ShutdownServer(context.Context, domain.WorkloadBinding, string, string, time.Time) error
|
||||
}
|
||||
type ServerConnectionRecorder interface {
|
||||
RecordPlayerConnected(context.Context, domain.WorkloadBinding, string, string, time.Time) error
|
||||
ClaimPlayerConnection(context.Context, domain.WorkloadBinding, string, uint64, string, time.Time) (uint64, error)
|
||||
RecordPlayerDisconnected(context.Context, domain.WorkloadBinding, string, uint64, string, time.Time) error
|
||||
}
|
||||
|
||||
type QueueBackend interface {
|
||||
@@ -559,7 +560,7 @@ func (s *Service) contractAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Service) contractServerMutation(w http.ResponseWriter, r *http.Request) {
|
||||
// Unlike contractAssignment, the documented shape here is two segments
|
||||
// (/servers/{serverId}/{result|register|roster|connect|shutdown}) — rejecting
|
||||
// (/servers/{serverId}/{result|register|roster|connect|disconnect|shutdown}) — rejecting
|
||||
// any "/" would 404 every real call. Delegate shape validation to
|
||||
// serverMutation, which already enforces the exact operation allowlist.
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/")
|
||||
@@ -592,7 +593,7 @@ type serverRegistrationRequest struct {
|
||||
|
||||
func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/servers/"), "/")
|
||||
if len(parts) != 2 || parts[0] == "" || (parts[1] != "result" && parts[1] != "register" && parts[1] != "roster" && parts[1] != "shutdown" && parts[1] != "connect") {
|
||||
if len(parts) != 2 || parts[0] == "" || (parts[1] != "result" && parts[1] != "register" && parts[1] != "roster" && parts[1] != "shutdown" && parts[1] != "connect" && parts[1] != "disconnect") {
|
||||
writeError(w, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
@@ -600,7 +601,7 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
if s.WorkloadVerify == nil || (parts[1] == "result" && s.ResultSubmitter == nil) || (parts[1] == "register" && s.ServerRegistrar == nil) || (parts[1] == "roster" && s.Roster == nil) || (parts[1] == "shutdown" && s.ServerShutdowner == nil) || (parts[1] == "connect" && s.ServerConnections == nil) {
|
||||
if s.WorkloadVerify == nil || (parts[1] == "result" && s.ResultSubmitter == nil) || (parts[1] == "register" && s.ServerRegistrar == nil) || (parts[1] == "roster" && s.Roster == nil) || (parts[1] == "shutdown" && s.ServerShutdowner == nil) || ((parts[1] == "connect" || parts[1] == "disconnect") && s.ServerConnections == nil) {
|
||||
writeError(w, http.StatusServiceUnavailable, "server_unavailable")
|
||||
return
|
||||
}
|
||||
@@ -666,9 +667,11 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if parts[1] == "connect" {
|
||||
if parts[1] == "connect" || parts[1] == "disconnect" {
|
||||
var input struct {
|
||||
PlayerID string `json:"player_id"`
|
||||
PlayerID string `json:"player_id"`
|
||||
Generation uint64 `json:"generation,omitempty"`
|
||||
ExpectedGeneration uint64 `json:"expected_generation,omitempty"`
|
||||
}
|
||||
if !decodeBody(w, r, &input) {
|
||||
return
|
||||
@@ -677,7 +680,23 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
return
|
||||
}
|
||||
if err := s.ServerConnections.RecordPlayerConnected(r.Context(), binding, input.PlayerID, key, now); err != nil {
|
||||
var generation uint64
|
||||
var err error
|
||||
if parts[1] == "connect" {
|
||||
if input.Generation != 0 {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
return
|
||||
}
|
||||
generation, err = s.ServerConnections.ClaimPlayerConnection(r.Context(), binding, input.PlayerID, input.ExpectedGeneration, key, now)
|
||||
} else {
|
||||
if input.Generation == 0 || input.ExpectedGeneration != 0 {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
||||
return
|
||||
}
|
||||
generation = input.Generation
|
||||
err = s.ServerConnections.RecordPlayerDisconnected(r.Context(), binding, input.PlayerID, input.Generation, key, now)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrConflict) {
|
||||
writeError(w, http.StatusConflict, "conflict")
|
||||
} else {
|
||||
@@ -686,11 +705,20 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
||||
// client fault; 503 keeps the game server's bounded retry alive.
|
||||
writeError(w, http.StatusServiceUnavailable, "server_unavailable")
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "server_connect", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
|
||||
s.logEvent(observability.Event{Event: "server_" + parts[1], MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
|
||||
return
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "server_connect", MatchID: binding.MatchID, ServerID: parts[0], Stage: "connected", OccurredAt: now, Fields: map[string]any{"player_id": input.PlayerID}})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
stage := "connected"
|
||||
if parts[1] == "disconnect" {
|
||||
stage = "disconnected"
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "server_" + parts[1], MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now, Fields: map[string]any{"player_id": input.PlayerID, "generation": generation}})
|
||||
if parts[1] == "disconnect" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]uint64{"generation": generation})
|
||||
return
|
||||
}
|
||||
if parts[1] == "shutdown" {
|
||||
|
||||
+38
-19
@@ -71,16 +71,25 @@ type serverShutdownerSpy struct {
|
||||
}
|
||||
|
||||
type serverConnectionSpy struct {
|
||||
calls int
|
||||
binding domain.WorkloadBinding
|
||||
playerID string
|
||||
key string
|
||||
err error
|
||||
connectCalls int
|
||||
disconnectCalls int
|
||||
binding domain.WorkloadBinding
|
||||
playerID string
|
||||
key string
|
||||
expectedGeneration uint64
|
||||
generation uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *serverConnectionSpy) RecordPlayerConnected(_ context.Context, binding domain.WorkloadBinding, playerID, key string, _ time.Time) error {
|
||||
s.calls++
|
||||
s.binding, s.playerID, s.key = binding, playerID, key
|
||||
func (s *serverConnectionSpy) ClaimPlayerConnection(_ context.Context, binding domain.WorkloadBinding, playerID string, expectedGeneration uint64, key string, _ time.Time) (uint64, error) {
|
||||
s.connectCalls++
|
||||
s.binding, s.playerID, s.expectedGeneration, s.key = binding, playerID, expectedGeneration, key
|
||||
return expectedGeneration + 1, s.err
|
||||
}
|
||||
|
||||
func (s *serverConnectionSpy) RecordPlayerDisconnected(_ context.Context, binding domain.WorkloadBinding, playerID string, generation uint64, key string, _ time.Time) error {
|
||||
s.disconnectCalls++
|
||||
s.binding, s.playerID, s.generation, s.key = binding, playerID, generation, key
|
||||
return s.err
|
||||
}
|
||||
|
||||
@@ -1485,35 +1494,45 @@ func TestServerConnectionAPIRequiresBoundWorkloadAndOpaqueAssignedPlayer(t *test
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
|
||||
request := func(serverID, playerID, token, key string) int {
|
||||
body := fmt.Sprintf(`{"player_id":%q}`, playerID)
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/"+serverID+"/connect", strings.NewReader(body))
|
||||
request := func(operation, serverID, playerID, token, key, bodySuffix string) (int, string) {
|
||||
body := fmt.Sprintf(`{"player_id":%q%s}`, playerID, bodySuffix)
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/"+serverID+"/"+operation, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Idempotency-Key", key)
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
responseBody, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
return response.StatusCode
|
||||
return response.StatusCode, string(responseBody)
|
||||
}
|
||||
if got := request(binding.ServerID, "player-123456789", "workload-token", "connect-player-123456789"); got != http.StatusNoContent {
|
||||
if got, body := request("connect", binding.ServerID, "player-123456789", "workload-token", "connect-player-123456789", `,"expected_generation":0`); got != http.StatusOK || !strings.Contains(body, `"generation":1`) {
|
||||
t.Fatalf("connection status = %d", got)
|
||||
}
|
||||
if recorder.calls != 1 || recorder.binding != binding || recorder.playerID != "player-123456789" || recorder.key != "connect-player-123456789" {
|
||||
if recorder.connectCalls != 1 || recorder.binding != binding || recorder.playerID != "player-123456789" || recorder.expectedGeneration != 0 || recorder.key != "connect-player-123456789" {
|
||||
t.Fatalf("connection receipt = %+v", recorder)
|
||||
}
|
||||
if got := request("server-000000000", "player-123456789", "workload-token", "connect-player-123456789"); got != http.StatusUnauthorized {
|
||||
if got, _ := request("connect", "server-000000000", "player-123456789", "workload-token", "connect-player-123456789", ""); got != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong server status = %d", got)
|
||||
}
|
||||
if got := request(binding.ServerID, "short", "workload-token", "connect-player-short-123"); got != http.StatusUnprocessableEntity {
|
||||
if got, _ := request("connect", binding.ServerID, "short", "workload-token", "connect-player-short-123", ""); got != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("short player status = %d", got)
|
||||
}
|
||||
if recorder.calls != 1 {
|
||||
t.Fatalf("invalid receipts reached backend: %d", recorder.calls)
|
||||
if recorder.connectCalls != 1 {
|
||||
t.Fatalf("invalid receipts reached backend: %d", recorder.connectCalls)
|
||||
}
|
||||
if got, _ := request("disconnect", binding.ServerID, "player-123456789", "workload-token", "disconnect-player-123456789", `,"generation":1`); got != http.StatusNoContent {
|
||||
t.Fatalf("disconnect status = %d", got)
|
||||
}
|
||||
if recorder.disconnectCalls != 1 || recorder.generation != 1 {
|
||||
t.Fatalf("disconnect receipt = %+v", recorder)
|
||||
}
|
||||
if got, _ := request("disconnect", binding.ServerID, "player-123456789", "workload-token", "disconnect-zero-123456", ""); got != http.StatusUnprocessableEntity || recorder.disconnectCalls != 1 {
|
||||
t.Fatalf("zero-generation disconnect status=%d calls=%d", got, recorder.disconnectCalls)
|
||||
}
|
||||
recorder.err = errors.New("database unavailable")
|
||||
if got := request(binding.ServerID, "player-123456789", "workload-token", "connect-player-retry-123"); got != http.StatusServiceUnavailable {
|
||||
if got, _ := request("connect", binding.ServerID, "player-123456789", "workload-token", "connect-player-retry-123", `,"expected_generation":1`); got != http.StatusServiceUnavailable {
|
||||
t.Fatalf("recorder outage status = %d, want retryable 503", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,12 @@ func ServerShutdownerFromStore(db *sql.DB) ServerShutdowner {
|
||||
|
||||
type postgresServerConnections struct{ db *sql.DB }
|
||||
|
||||
func (p postgresServerConnections) RecordPlayerConnected(ctx context.Context, binding domain.WorkloadBinding, playerID, idempotencyKey string, now time.Time) error {
|
||||
return store.RecordPlayerConnected(ctx, p.db, binding, playerID, idempotencyKey, now)
|
||||
func (p postgresServerConnections) ClaimPlayerConnection(ctx context.Context, binding domain.WorkloadBinding, playerID string, expectedGeneration uint64, idempotencyKey string, now time.Time) (uint64, error) {
|
||||
return store.ClaimPlayerConnection(ctx, p.db, binding, playerID, expectedGeneration, idempotencyKey, now)
|
||||
}
|
||||
|
||||
func (p postgresServerConnections) RecordPlayerDisconnected(ctx context.Context, binding domain.WorkloadBinding, playerID string, generation uint64, idempotencyKey string, now time.Time) error {
|
||||
return store.RecordPlayerDisconnected(ctx, p.db, binding, playerID, generation, idempotencyKey, now)
|
||||
}
|
||||
|
||||
func ServerConnectionsFromStore(db *sql.DB) ServerConnectionRecorder {
|
||||
|
||||
Reference in New Issue
Block a user