mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat: add deterministic allocation policy
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerLifecycle string
|
||||
|
||||
const (
|
||||
ServerReady ServerLifecycle = "READY"
|
||||
ServerAllocated ServerLifecycle = "ALLOCATED"
|
||||
)
|
||||
|
||||
type ReadyServer struct {
|
||||
ServerID string
|
||||
Region string
|
||||
Build string
|
||||
Protocol int
|
||||
Transport string
|
||||
State ServerLifecycle
|
||||
}
|
||||
|
||||
type AllocationRequest struct {
|
||||
AllocationID string
|
||||
MatchID string
|
||||
Region string
|
||||
Build string
|
||||
Protocol int
|
||||
Transport string
|
||||
}
|
||||
|
||||
type Allocation struct {
|
||||
AllocationID string
|
||||
MatchID string
|
||||
ServerID string
|
||||
State ServerLifecycle
|
||||
AllocatedAt time.Time
|
||||
}
|
||||
|
||||
type Allocator struct {
|
||||
mu sync.Mutex
|
||||
servers map[string]ReadyServer
|
||||
allocations map[string]Allocation
|
||||
requestHashes map[string][32]byte
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoCapacity = fmt.Errorf("no compatible ready server")
|
||||
ErrAllocationInput = fmt.Errorf("invalid allocation request")
|
||||
)
|
||||
|
||||
func NewAllocator(servers []ReadyServer) (*Allocator, error) {
|
||||
a := &Allocator{servers: make(map[string]ReadyServer, len(servers)), allocations: make(map[string]Allocation), requestHashes: make(map[string][32]byte)}
|
||||
for _, server := range servers {
|
||||
if server.ServerID == "" || server.Region == "" || server.Build == "" || server.Protocol <= 0 || (server.Transport != "enet" && server.Transport != "steam_sdr") || server.State != ServerReady {
|
||||
return nil, fmt.Errorf("%w: invalid ready server", ErrAllocationInput)
|
||||
}
|
||||
if _, exists := a.servers[server.ServerID]; exists {
|
||||
return nil, fmt.Errorf("%w: duplicate server", ErrAllocationInput)
|
||||
}
|
||||
a.servers[server.ServerID] = server
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Allocate is the in-process equivalent of a GameServerAllocation. The mutex
|
||||
// represents the durable allocator transaction; the PostgreSQL/Agones adapter
|
||||
// must preserve this claim-before-assignment ordering across replicas.
|
||||
func (a *Allocator) Allocate(request AllocationRequest, now time.Time) (Allocation, error) {
|
||||
if err := validateAllocationRequest(request); err != nil {
|
||||
return Allocation{}, err
|
||||
}
|
||||
digest := allocationDigest(request)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if prior, ok := a.allocations[request.AllocationID]; ok {
|
||||
if a.requestHashes[request.AllocationID] != digest {
|
||||
return Allocation{}, ErrConflict
|
||||
}
|
||||
return prior, nil
|
||||
}
|
||||
ids := make([]string, 0)
|
||||
for id, server := range a.servers {
|
||||
if server.State == ServerReady && server.Region == request.Region && server.Build == request.Build && server.Protocol == request.Protocol && server.Transport == request.Transport {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return Allocation{}, ErrNoCapacity
|
||||
}
|
||||
sort.Strings(ids)
|
||||
server := a.servers[ids[0]]
|
||||
server.State = ServerAllocated
|
||||
a.servers[server.ServerID] = server
|
||||
allocation := Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: server.ServerID, State: ServerAllocated, AllocatedAt: now}
|
||||
a.allocations[request.AllocationID] = allocation
|
||||
a.requestHashes[request.AllocationID] = digest
|
||||
return allocation, nil
|
||||
}
|
||||
|
||||
func validateAllocationRequest(request AllocationRequest) error {
|
||||
if request.AllocationID == "" || request.MatchID == "" || request.Region == "" || request.Build == "" || request.Protocol <= 0 || (request.Transport != "enet" && request.Transport != "steam_sdr") {
|
||||
return ErrAllocationInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allocationDigest(request AllocationRequest) [32]byte {
|
||||
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport)))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAllocatorFiltersAndAtomicallyClaimsCompatibleReadyServer(t *testing.T) {
|
||||
a, err := NewAllocator([]ReadyServer{
|
||||
{ServerID: "server-b", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: ServerReady},
|
||||
{ServerID: "server-a", Region: "NA", Build: "build-1", Protocol: 1, Transport: "enet", State: ServerReady},
|
||||
{ServerID: "server-c", Region: "EU", Build: "build-2", Protocol: 1, Transport: "enet", State: ServerReady},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := AllocationRequest{AllocationID: "allocation-1", MatchID: "match-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}
|
||||
got, err := a.Allocate(request, time.Unix(1000, 0))
|
||||
if err != nil || got.ServerID != "server-b" || got.State != ServerAllocated {
|
||||
t.Fatalf("allocation = %+v err=%v", got, err)
|
||||
}
|
||||
if _, err := a.Allocate(AllocationRequest{AllocationID: "allocation-2", MatchID: "match-2", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, time.Unix(1001, 0)); !errors.Is(err, ErrNoCapacity) {
|
||||
t.Fatalf("claimed server was reused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatorIsIdempotentAndRejectsConflictingReplay(t *testing.T) {
|
||||
a, _ := NewAllocator([]ReadyServer{{ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "steam_sdr", State: ServerReady}})
|
||||
request := AllocationRequest{AllocationID: "allocation-1", MatchID: "match-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "steam_sdr"}
|
||||
first, err := a.Allocate(request, time.Unix(1000, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replay, err := a.Allocate(request, time.Unix(2000, 0))
|
||||
if err != nil || replay != first {
|
||||
t.Fatalf("replay = %+v err=%v", replay, err)
|
||||
}
|
||||
request.MatchID = "match-2"
|
||||
if _, err := a.Allocate(request, time.Unix(2000, 0)); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("conflicting replay = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatorRejectsInvalidServerAndNoCompatibleCapacity(t *testing.T) {
|
||||
if _, err := NewAllocator([]ReadyServer{{ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "udp", State: ServerReady}}); !errors.Is(err, ErrAllocationInput) {
|
||||
t.Fatalf("invalid server accepted: %v", err)
|
||||
}
|
||||
a, _ := NewAllocator(nil)
|
||||
if _, err := a.Allocate(AllocationRequest{AllocationID: "a", MatchID: "m", Region: "EU", Build: "b", Protocol: 1, Transport: "enet"}, time.Unix(1000, 0)); !errors.Is(err, ErrNoCapacity) {
|
||||
t.Fatalf("empty allocator error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatorConcurrentClaimsCannotDoubleAllocateOneServer(t *testing.T) {
|
||||
a, _ := NewAllocator([]ReadyServer{{ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: ServerReady}})
|
||||
requests := []AllocationRequest{
|
||||
{AllocationID: "allocation-a", MatchID: "match-a", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"},
|
||||
{AllocationID: "allocation-b", MatchID: "match-b", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"},
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, len(requests))
|
||||
for _, request := range requests {
|
||||
wg.Add(1)
|
||||
go func(request AllocationRequest) {
|
||||
defer wg.Done()
|
||||
_, err := a.Allocate(request, time.Unix(1000, 0))
|
||||
results <- err
|
||||
}(request)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
wins := 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if !errors.Is(err, ErrNoCapacity) {
|
||||
t.Fatalf("unexpected concurrent claim error: %v", err)
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("concurrent claims succeeded %d times", wins)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user