feat(multiplayer): persist connection generation leases

This commit is contained in:
Josh Creek
2026-09-03 13:38:13 +01:00
parent 2e9da3032c
commit 3e0022ce9c
10 changed files with 286 additions and 80 deletions
+38 -10
View File
@@ -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" {