mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
test(multiplayer): add real concurrent allocation-claim integration test
Fires more concurrent ClaimAllocation calls than there is Ready capacity at a real PostgreSQL instance and asserts: exactly as many win as there was capacity, every winner gets a distinct server (no double-booking), every loser gets ErrNoCapacity rather than a raw serialization error or a hang, and the durable game_servers.state count matches. This is the cross-allocator-replica race 8.30 calls out as untested -- the existing capacity test in this file claims strictly one request at a time. Verified clean across 6 runs with -race, plus the full integration and unit suites.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -102,6 +103,78 @@ func TestPostgreSQLAllocatorClaimReplayAndCapacityFence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostgreSQLConcurrentAllocationClaimNeverDoubleBooksAReadyServer is the
|
||||
// live counterpart to TestPostgreSQLAllocatorClaimReplayAndCapacityFence: that
|
||||
// test claims strictly one request at a time, so it cannot show what happens
|
||||
// when two allocator replicas race for the same compatible capacity, which is
|
||||
// exactly the scenario 8.30's "bounded cross-replica retry" is about. Register
|
||||
// fewer Ready servers than concurrent requests and fire them all at once;
|
||||
// exactly as many must win as there was capacity, each winner must get a
|
||||
// distinct server, and every loser must fail with ErrNoCapacity rather than a
|
||||
// raw serialization error, a duplicate claim, or a hang.
|
||||
func TestPostgreSQLConcurrentAllocationClaimNeverDoubleBooksAReadyServer(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
ctx := context.Background()
|
||||
const capacity = 3
|
||||
const contenders = 6
|
||||
for i := 0; i < capacity; i++ {
|
||||
server := domain.ReadyServer{ServerID: fmt.Sprintf("race-server-%d", i), Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}
|
||||
if err := RegisterReadyServer(ctx, db, server, now); err != nil {
|
||||
t.Fatalf("register %s: %v", server.ServerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
allocations := make([]domain.Allocation, contenders)
|
||||
errs := make([]error, contenders)
|
||||
wg.Add(contenders)
|
||||
for i := 0; i < contenders; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
request := domain.AllocationRequest{AllocationID: fmt.Sprintf("race-allocation-%d", i), MatchID: fmt.Sprintf("race-match-%d", i), Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}
|
||||
allocations[i], errs[i] = ClaimAllocation(ctx, db, request, now)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
wonServers := map[string]int{}
|
||||
won, lost := 0, 0
|
||||
for i, err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
won++
|
||||
if allocations[i].ServerID == "" {
|
||||
t.Fatalf("claim %d succeeded with no server", i)
|
||||
}
|
||||
wonServers[allocations[i].ServerID]++
|
||||
case errors.Is(err, domain.ErrNoCapacity):
|
||||
lost++
|
||||
default:
|
||||
t.Fatalf("claim %d failed with unexpected error: %v", i, err)
|
||||
}
|
||||
}
|
||||
if won != capacity || lost != contenders-capacity {
|
||||
t.Fatalf("won=%d lost=%d, want won=%d lost=%d", won, lost, capacity, contenders-capacity)
|
||||
}
|
||||
if len(wonServers) != capacity {
|
||||
t.Fatalf("expected %d distinct servers claimed, got %d: %v", capacity, len(wonServers), wonServers)
|
||||
}
|
||||
for server, count := range wonServers {
|
||||
if count != 1 {
|
||||
t.Fatalf("server %s was claimed %d times", server, count)
|
||||
}
|
||||
}
|
||||
var allocatedCount int
|
||||
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM game_servers WHERE state = 'ALLOCATED'`).Scan(&allocatedCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allocatedCount != capacity {
|
||||
t.Fatalf("durable ALLOCATED server count = %d, want %d", allocatedCount, capacity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLAcceptedProposalPromotesOneAtomicAllocatingMatch(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
|
||||
Reference in New Issue
Block a user