mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 13:12:23 +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"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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) {
|
func TestPostgreSQLAcceptedProposalPromotesOneAtomicAllocatingMatch(t *testing.T) {
|
||||||
db := openIntegrationPostgres(t)
|
db := openIntegrationPostgres(t)
|
||||||
applyIntegrationMigrations(t, db)
|
applyIntegrationMigrations(t, db)
|
||||||
|
|||||||
Reference in New Issue
Block a user