mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): add degraded admission mode
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user