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:
Josh Creek
2026-09-01 13:14:46 +01:00
parent ea6939e18d
commit 42ac34ca70
+73
View File
@@ -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)