diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index 9dbf214a..ba9eafd8 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -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)