feat(multiplayer): add regional allocation budget

This commit is contained in:
Josh Creek
2026-09-01 18:25:25 +01:00
parent c4c2ada1f6
commit 181a928c87
5 changed files with 146 additions and 1 deletions
+49
View File
@@ -0,0 +1,49 @@
package allocator
import (
"fmt"
"sync"
"time"
)
// ErrAllocationBudgetExceeded is deliberately generic: callers should not
// learn quota internals, and the allocator can safely retry the leased match
// after the current window expires.
var ErrAllocationBudgetExceeded = fmt.Errorf("allocation budget exceeded")
// FixedWindowBudget is a process-local denial-of-wallet guard. It limits the
// number of provider allocation attempts per region in a time window. The
// production deployment must use the same policy behind a shared durable
// counter for a global quota; this type prevents one allocator replica from
// spending without bound and is useful in tests and single-replica setups.
type FixedWindowBudget struct {
mu sync.Mutex
limit int
window time.Duration
windowStart time.Time
counts map[string]int
}
func NewFixedWindowBudget(limit int, window time.Duration) (*FixedWindowBudget, error) {
if limit < 1 || window <= 0 {
return nil, fmt.Errorf("invalid allocation budget")
}
return &FixedWindowBudget{limit: limit, window: window, counts: make(map[string]int)}, nil
}
func (b *FixedWindowBudget) Allow(region string, now time.Time) error {
if b == nil || (region != "EU" && region != "NA") || now.IsZero() {
return fmt.Errorf("invalid allocation budget request")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.windowStart.IsZero() || !now.Before(b.windowStart.Add(b.window)) {
b.windowStart = now
b.counts = make(map[string]int)
}
if b.counts[region] >= b.limit {
return ErrAllocationBudgetExceeded
}
b.counts[region]++
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package allocator
import (
"errors"
"sync"
"testing"
"time"
)
func TestFixedWindowBudgetLimitsEachRegionAndResets(t *testing.T) {
now := time.Unix(1000, 0).UTC()
budget, err := NewFixedWindowBudget(2, time.Minute)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := budget.Allow("EU", now); err != nil {
t.Fatalf("EU attempt %d: %v", i, err)
}
}
if err := budget.Allow("EU", now); !errors.Is(err, ErrAllocationBudgetExceeded) {
t.Fatalf("third EU attempt = %v, want budget error", err)
}
if err := budget.Allow("NA", now); err != nil {
t.Fatalf("NA should have an independent budget: %v", err)
}
if err := budget.Allow("EU", now.Add(time.Minute)); err != nil {
t.Fatalf("EU after window: %v", err)
}
}
func TestFixedWindowBudgetIsAtomicUnderConcurrentAttempts(t *testing.T) {
budget, err := NewFixedWindowBudget(7, time.Minute)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1000, 0).UTC()
var wg sync.WaitGroup
var mu sync.Mutex
allowed := 0
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if budget.Allow("EU", now) == nil {
mu.Lock()
allowed++
mu.Unlock()
}
}()
}
wg.Wait()
if allowed != 7 {
t.Fatalf("allowed=%d, want exactly 7", allowed)
}
}
func TestFixedWindowBudgetRejectsInvalidConfigurationAndInput(t *testing.T) {
if _, err := NewFixedWindowBudget(0, time.Minute); err == nil {
t.Fatal("zero limit accepted")
}
if _, err := NewFixedWindowBudget(1, 0); err == nil {
t.Fatal("zero window accepted")
}
budget, _ := NewFixedWindowBudget(1, time.Minute)
if err := budget.Allow("APAC", time.Unix(1000, 0)); err == nil {
t.Fatal("unknown region accepted")
}
if err := budget.Allow("EU", time.Time{}); err == nil {
t.Fatal("zero time accepted")
}
}
+10
View File
@@ -26,10 +26,15 @@ type RosterPublisher interface {
PublishRoster(context.Context, domain.Assignment, []domain.SignedJoinAuthorisation, func([]byte, []byte) bool) error
}
type AllocationBudget interface {
Allow(region string, now time.Time) error
}
type Service struct {
Provider Provider
Durable Durable
Roster RosterPublisher
Budget AllocationBudget
Now func() time.Time
}
@@ -78,6 +83,11 @@ func (s Service) Allocate(ctx context.Context, request domain.AllocationRequest,
return agones.AllocatedServer{}, errNotConfigured
}
now := s.Now()
if s.Budget != nil {
if err := s.Budget.Allow(request.Region, now); err != nil {
return agones.AllocatedServer{}, err
}
}
result, err := s.Provider.Allocate(ctx, request, labels, now)
if err != nil {
return agones.AllocatedServer{}, err