feat(multiplayer): add degraded admission mode

This commit is contained in:
Josh Creek
2026-09-01 19:14:28 +01:00
parent d04523accd
commit 6366b5e1f6
6 changed files with 196 additions and 1 deletions
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"strings"
"sync/atomic"
)
// AdmissionController decides whether a classified public operation may
// start. Implementations must be safe for concurrent requests.
type AdmissionController interface {
Allow(operation string) bool
}
// AdmissionGate is the operator-controlled overload gate for new matchmaking
// work. Degraded mode is deliberately narrow: existing matches can continue
// to report results and clients can still use read/recovery/event endpoints.
type AdmissionGate struct {
degraded atomic.Bool
}
func NewAdmissionGate(degraded bool) *AdmissionGate {
gate := &AdmissionGate{}
gate.degraded.Store(degraded)
return gate
}
func (g *AdmissionGate) SetDegraded(value bool) {
if g != nil {
g.degraded.Store(value)
}
}
func (g *AdmissionGate) Degraded() bool {
return g != nil && g.degraded.Load()
}
func (g *AdmissionGate) Allow(operation string) bool {
if !g.Degraded() {
return true
}
switch operation {
case "login", "queue", "proposal", "allocation":
return false
default:
return true
}
}
func admissionOperation(path, method string) string {
if method == "GET" || method == "HEAD" || method == "OPTIONS" {
return ""
}
path = strings.TrimSuffix(path, "/")
switch {
case path == "/v1/session/steam" || path == "/api/v1/session/steam":
return "login"
case path == "/v1/queue" || path == "/api/v1/queue/tickets":
return "queue"
case underPath(path, "/v1/queue/") || underPath(path, "/api/v1/queue/tickets/"):
return "queue"
case underPath(path, "/v1/proposals/") || underPath(path, "/api/v1/proposals/"):
return "proposal"
default:
return ""
}
}
func underPath(path, prefix string) bool {
return strings.HasPrefix(path, prefix) && len(path) > len(prefix)
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
func TestAdmissionGateDefaultsToAllowAndBlocksOnlyNewWork(t *testing.T) {
gate := NewAdmissionGate(false)
for _, operation := range []string{"login", "queue", "proposal", "allocation", "result", "events"} {
if !gate.Allow(operation) {
t.Fatalf("normal mode rejected %q", operation)
}
}
gate.SetDegraded(true)
for _, operation := range []string{"login", "queue", "proposal", "allocation"} {
if gate.Allow(operation) {
t.Fatalf("degraded mode allowed %q", operation)
}
}
for _, operation := range []string{"result", "events", "read", ""} {
if !gate.Allow(operation) {
t.Fatalf("degraded mode rejected live-safe operation %q", operation)
}
}
}
func TestAdmissionOperationClassifiesOnlyMutations(t *testing.T) {
tests := []struct {
path, method, want string
}{
{"/v1/session/steam", http.MethodPost, "login"},
{"/api/v1/session/steam/", http.MethodPost, "login"},
{"/v1/queue", http.MethodPost, "queue"},
{"/api/v1/queue/tickets/abc/heartbeat", http.MethodPost, "queue"},
{"/v1/proposals/abc/accept", http.MethodPost, "proposal"},
{"/api/v1/proposals/abc", http.MethodDelete, "proposal"},
{"/v1/queue", http.MethodGet, ""},
{"/v1/queue-not-a-route", http.MethodPost, ""},
{"/v1/servers/abc/result", http.MethodPost, ""},
{"/v1/events", http.MethodPost, ""},
}
for _, test := range tests {
if got := admissionOperation(test.path, test.method); got != test.want {
t.Errorf("admissionOperation(%q, %q) = %q, want %q", test.path, test.method, got, test.want)
}
}
}
func TestAdmissionGateConcurrentToggle(t *testing.T) {
gate := NewAdmissionGate(false)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
gate.SetDegraded(j%2 == 0)
_ = gate.Allow("queue")
}
}()
}
wg.Wait()
}
func TestHandlerReturnsDegradedOnlyForNewMatchmakingMutations(t *testing.T) {
service := &Service{Admission: NewAdmissionGate(true)}
tests := []struct {
path, want string
}{
{"/v1/queue", "service_degraded"},
{"/api/v1/proposals/proposal-1/accept", "service_degraded"},
{"/v1/servers/server-1/result", "server_unavailable"},
{"/v1/events", ""},
}
for _, test := range tests {
req := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(`{}`))
rec := httptest.NewRecorder()
service.Handler().ServeHTTP(rec, req)
if test.want != "" && !strings.Contains(rec.Body.String(), test.want) {
t.Errorf("%s body = %q, want %q", test.path, rec.Body.String(), test.want)
}
if test.want == "service_degraded" && rec.Code != http.StatusServiceUnavailable {
t.Errorf("%s status = %d, want 503", test.path, rec.Code)
}
}
}
+11
View File
@@ -127,6 +127,7 @@ type Service struct {
RankedProfileProvider RankedProfileProvider
TierPolicy domain.TierPolicy
RateLimiter *RateLimiter
Admission AdmissionController
// Log receives a credential-safe structured event for lifecycle-relevant
// reads and mutations. Nil
// is a valid, silent no-op -- every call site must stay optional so
@@ -213,6 +214,16 @@ func (s *Service) Handler() http.Handler {
mux.ServeHTTP(w, r)
})
}
if s.Admission != nil {
admissionHandler := handler
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if operation := admissionOperation(r.URL.Path, r.Method); operation != "" && !s.Admission.Allow(operation) {
writeError(w, http.StatusServiceUnavailable, "service_degraded")
return
}
admissionHandler.ServeHTTP(w, r)
})
}
if s.Metrics == nil {
return handler
}