diff --git a/multiplayer-next.md b/multiplayer-next.md index c267d52a..346533c9 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1451,7 +1451,7 @@ The same allocation path now carries the matcher-selected playlist, preventing a Ranked proposal admission no longer trusts the matcher’s `--ranked-random-arena` boolean. The Go domain now owns a named allowlist for the three floor-goal `ArenaRegistry` entries, and rejects unknown and elevated IDs before any proposal is created. -The arena hand-off is now durable: the matcher deterministically selects an eligible floor-goal arena from the proposal ID, migration 0008 stores that path on proposals and matches and enforces it for new direct SQL writes, each durable transition rechecks the same allowlist, allocation claims and idempotency digests retain it, Agones applies it as a match-scoped annotation, and the supervisor overlays the allocated child’s `--arena-path`. Godot accepts only the same floor-goal `ArenaRegistry` paths and requires one for allocated ranked matches, so a stale Fleet default, an elevated variant, or an altered retry cannot substitute a ranked arena. +The arena hand-off is now durable: the matcher deterministically selects an eligible floor-goal arena from the proposal ID, migration 0008 stores that path on proposals and matches and enforces it for new direct SQL writes, migration 0009 retains it on provider allocations, each durable transition and recovery lookup rechecks the same allowlist, allocation claims and idempotency digests retain it, Agones applies it as a match-scoped annotation, and the supervisor overlays the allocated child’s `--arena-path`. Godot accepts only the same floor-goal `ArenaRegistry` paths and requires one for allocated ranked matches, so a stale Fleet default, an elevated variant, or an altered retry cannot substitute a ranked arena. The Godot control-plane client now retains the exact last idempotent mutation and exposes `retry_last_mutation()` for transport, timeout, rate-limit, and 5xx failures. Retries reuse the original idempotency key and expected revision, while 401 and 409 responses remain non-retryable; the harness covers the policy boundary. This closes the local duplicate-action recovery mechanism for heartbeat/cancel/proposal calls, with broader live UI retry verification still remaining. diff --git a/server/agones/allocation.go b/server/agones/allocation.go index 0cacffdc..f93c86f5 100644 --- a/server/agones/allocation.go +++ b/server/agones/allocation.go @@ -156,7 +156,7 @@ func (c Client) RecoverAllocation(ctx context.Context, request domain.Allocation if err != nil { return AllocatedServer{}, false, err } - recovered = AllocatedServer{Allocation: domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: item.Metadata.Name, Region: request.Region, Build: request.Build, Protocol: request.Protocol, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now}, Endpoint: net.JoinHostPort(item.Status.Address, strconv.Itoa(port)), GameServer: item.Metadata.Name} + recovered = AllocatedServer{Allocation: domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: item.Metadata.Name, Region: request.Region, Build: request.Build, Protocol: request.Protocol, ArenaPath: request.ArenaPath, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now}, Endpoint: net.JoinHostPort(item.Status.Address, strconv.Itoa(port)), GameServer: item.Metadata.Name} found = true } return recovered, found, nil @@ -291,7 +291,7 @@ func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest, if err != nil { return AllocatedServer{}, err } - return AllocatedServer{Allocation: domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: decoded.Status.GameServerName, Region: request.Region, Build: request.Build, Protocol: request.Protocol, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now}, Endpoint: net.JoinHostPort(decoded.Status.Address, strconv.Itoa(port)), GameServer: decoded.Status.GameServerName}, nil + return AllocatedServer{Allocation: domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: decoded.Status.GameServerName, Region: request.Region, Build: request.Build, Protocol: request.Protocol, ArenaPath: request.ArenaPath, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now}, Endpoint: net.JoinHostPort(decoded.Status.Address, strconv.Itoa(port)), GameServer: decoded.Status.GameServerName}, nil } func (c Client) endpoint() (string, error) { diff --git a/server/domain/allocator.go b/server/domain/allocator.go index 27eee59b..b5277485 100644 --- a/server/domain/allocator.go +++ b/server/domain/allocator.go @@ -42,6 +42,7 @@ type Allocation struct { Region string Build string Protocol int + ArenaPath string Transport string State ServerLifecycle AllocatedAt time.Time @@ -104,7 +105,7 @@ func (a *Allocator) Allocate(request AllocationRequest, now time.Time) (Allocati server := a.servers[ids[0]] server.State = ServerAllocated a.servers[server.ServerID] = server - allocation := Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: server.ServerID, Region: server.Region, Build: server.Build, Protocol: server.Protocol, Transport: server.Transport, State: ServerAllocated, AllocatedAt: now} + allocation := Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: server.ServerID, Region: server.Region, Build: server.Build, Protocol: server.Protocol, ArenaPath: request.ArenaPath, Transport: server.Transport, State: ServerAllocated, AllocatedAt: now} a.allocations[request.AllocationID] = allocation a.requestHashes[request.AllocationID] = digest return allocation, nil diff --git a/server/migrations/0009_allocation_arena_paths.sql b/server/migrations/0009_allocation_arena_paths.sql new file mode 100644 index 00000000..cf2f2152 --- /dev/null +++ b/server/migrations/0009_allocation_arena_paths.sql @@ -0,0 +1,4 @@ +-- Keep arena identity in the durable provider allocation record so retries +-- and provider recovery compare the complete match compatibility tuple. +ALTER TABLE allocations + ADD COLUMN arena_path TEXT; diff --git a/server/migrations/down/0009_allocation_arena_paths.sql b/server/migrations/down/0009_allocation_arena_paths.sql new file mode 100644 index 00000000..5f7537b2 --- /dev/null +++ b/server/migrations/down/0009_allocation_arena_paths.sql @@ -0,0 +1,2 @@ +ALTER TABLE allocations + DROP COLUMN IF EXISTS arena_path; diff --git a/server/migrations/test_migration.py b/server/migrations/test_migration.py index 0c3162ef..a85c0805 100644 --- a/server/migrations/test_migration.py +++ b/server/migrations/test_migration.py @@ -8,6 +8,7 @@ SQL = (Path(__file__).parent / "0001_initial.sql").read_text() ASSIGNMENTS_SQL = (Path(__file__).parent / "0002_assignments.sql").read_text() QUOTAS_SQL = (Path(__file__).parent / "0007_allocation_quotas.sql").read_text() ARENAS_SQL = (Path(__file__).parent / "0008_match_arena_paths.sql").read_text() +ALLOCATION_ARENAS_SQL = (Path(__file__).parent / "0009_allocation_arena_paths.sql").read_text() class MigrationTest(unittest.TestCase): @@ -68,6 +69,10 @@ class MigrationTest(unittest.TestCase): ): self.assertIn(fragment, ARENAS_SQL) + def test_provider_allocation_retains_arena_identity(self): + self.assertIn("ALTER TABLE allocations", ALLOCATION_ARENAS_SQL) + self.assertIn("ADD COLUMN arena_path TEXT", ALLOCATION_ARENAS_SQL) + if __name__ == "__main__": unittest.main() diff --git a/server/store/allocation_match_sql.go b/server/store/allocation_match_sql.go index 54f04c0d..ab0bba6f 100644 --- a/server/store/allocation_match_sql.go +++ b/server/store/allocation_match_sql.go @@ -175,7 +175,7 @@ func FindProviderAllocation(ctx context.Context, db *sql.DB, request domain.Allo } var allocation domain.Allocation var digest []byte - err := db.QueryRowContext(ctx, SelectAllocationSQL, request.AllocationID).Scan(&allocation.AllocationID, &allocation.MatchID, &allocation.ServerID, &allocation.Region, &allocation.Build, &allocation.Protocol, &allocation.Transport, &allocation.AllocatedAt, &digest) + err := db.QueryRowContext(ctx, SelectAllocationSQL, request.AllocationID).Scan(&allocation.AllocationID, &allocation.MatchID, &allocation.ServerID, &allocation.Region, &allocation.Build, &allocation.Protocol, &allocation.ArenaPath, &allocation.Transport, &allocation.AllocatedAt, &digest) if err == sql.ErrNoRows { return domain.Allocation{}, false, nil } @@ -183,7 +183,7 @@ func FindProviderAllocation(ctx context.Context, db *sql.DB, request domain.Allo return domain.Allocation{}, false, err } want := allocationRequestDigest(request) - if !bytes.Equal(digest, want[:]) || allocation.MatchID != request.MatchID || allocation.Region != request.Region || allocation.Build != request.Build || allocation.Protocol != request.Protocol || allocation.Transport != request.Transport { + if !bytes.Equal(digest, want[:]) || allocation.MatchID != request.MatchID || allocation.Region != request.Region || allocation.Build != request.Build || allocation.Protocol != request.Protocol || allocation.ArenaPath != request.ArenaPath || allocation.Transport != request.Transport { return domain.Allocation{}, false, domain.ErrConflict } allocation.State = domain.ServerAllocated diff --git a/server/store/allocator_sql.go b/server/store/allocator_sql.go index 67422ba8..7ad7fd70 100644 --- a/server/store/allocator_sql.go +++ b/server/store/allocator_sql.go @@ -31,11 +31,11 @@ WHERE server_id = ( RETURNING server_id` const InsertAllocationSQL = `INSERT INTO allocations - (allocation_id, match_id, server_id, region, build, protocol_version, transport, request_digest, state, allocated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ALLOCATED', $9)` + (allocation_id, match_id, server_id, region, build, protocol_version, arena_path, transport, request_digest, state, allocated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'ALLOCATED', $10)` const SelectAllocationSQL = `SELECT allocation_id, match_id, server_id, region, build, - protocol_version, transport, allocated_at, request_digest + protocol_version, arena_path, transport, allocated_at, request_digest FROM allocations WHERE allocation_id = $1` const ProviderServerClaimSQL = `UPDATE game_servers SET state = 'ALLOCATED', updated_at = $6 @@ -63,7 +63,7 @@ func ClaimAllocation(ctx context.Context, db *sql.DB, request domain.AllocationR err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { var prior domain.Allocation var priorDigest []byte - err := tx.QueryRowContext(ctx, SelectAllocationSQL, request.AllocationID).Scan(&prior.AllocationID, &prior.MatchID, &prior.ServerID, &prior.Region, &prior.Build, &prior.Protocol, &prior.Transport, &prior.AllocatedAt, &priorDigest) + err := tx.QueryRowContext(ctx, SelectAllocationSQL, request.AllocationID).Scan(&prior.AllocationID, &prior.MatchID, &prior.ServerID, &prior.Region, &prior.Build, &prior.Protocol, &prior.ArenaPath, &prior.Transport, &prior.AllocatedAt, &priorDigest) if err == nil { if !bytes.Equal(priorDigest, digest[:]) { return domain.ErrConflict @@ -85,8 +85,8 @@ func ClaimAllocation(ctx context.Context, db *sql.DB, request domain.AllocationR } return err } - allocation = domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: serverID, Region: request.Region, Build: request.Build, Protocol: request.Protocol, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now} - _, err = tx.ExecContext(ctx, InsertAllocationSQL, request.AllocationID, request.MatchID, serverID, request.Region, request.Build, request.Protocol, request.Transport, digest[:], now) + allocation = domain.Allocation{AllocationID: request.AllocationID, MatchID: request.MatchID, ServerID: serverID, Region: request.Region, Build: request.Build, Protocol: request.Protocol, ArenaPath: request.ArenaPath, Transport: request.Transport, State: domain.ServerAllocated, AllocatedAt: now} + _, err = tx.ExecContext(ctx, InsertAllocationSQL, request.AllocationID, request.MatchID, serverID, request.Region, request.Build, request.Protocol, request.ArenaPath, request.Transport, digest[:], now) return err }) return allocation, err @@ -97,7 +97,7 @@ func ClaimAllocation(ctx context.Context, db *sql.DB, request domain.AllocationR // Agones has already selected the server; no client-facing assignment may use // the result until this exact tuple is durably recorded. func RecordProviderAllocation(ctx context.Context, db *sql.DB, allocation domain.Allocation, now time.Time) (domain.Allocation, error) { - request := domain.AllocationRequest{AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, Region: allocation.Region, Build: allocation.Build, Protocol: allocation.Protocol, Transport: allocation.Transport} + request := domain.AllocationRequest{AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, Region: allocation.Region, Build: allocation.Build, Protocol: allocation.Protocol, ArenaPath: allocation.ArenaPath, Transport: allocation.Transport} if !validAllocationInput(db, request, now) || allocation.State != domain.ServerAllocated || allocation.ServerID == "" { return domain.Allocation{}, domain.ErrAllocationInput } @@ -106,7 +106,7 @@ func RecordProviderAllocation(ctx context.Context, db *sql.DB, allocation domain err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { var prior domain.Allocation var priorDigest []byte - err := tx.QueryRowContext(ctx, SelectAllocationSQL, allocation.AllocationID).Scan(&prior.AllocationID, &prior.MatchID, &prior.ServerID, &prior.Region, &prior.Build, &prior.Protocol, &prior.Transport, &prior.AllocatedAt, &priorDigest) + err := tx.QueryRowContext(ctx, SelectAllocationSQL, allocation.AllocationID).Scan(&prior.AllocationID, &prior.MatchID, &prior.ServerID, &prior.Region, &prior.Build, &prior.Protocol, &prior.ArenaPath, &prior.Transport, &prior.AllocatedAt, &priorDigest) if err == nil { if !bytes.Equal(priorDigest, digest[:]) || prior.ServerID != allocation.ServerID { return domain.ErrConflict @@ -133,7 +133,7 @@ func RecordProviderAllocation(ctx context.Context, db *sql.DB, allocation domain } recorded = allocation recorded.AllocatedAt = now - _, err = tx.ExecContext(ctx, InsertAllocationSQL, allocation.AllocationID, allocation.MatchID, serverID, allocation.Region, allocation.Build, allocation.Protocol, allocation.Transport, digest[:], now) + _, err = tx.ExecContext(ctx, InsertAllocationSQL, allocation.AllocationID, allocation.MatchID, serverID, allocation.Region, allocation.Build, allocation.Protocol, allocation.ArenaPath, allocation.Transport, digest[:], now) return err }) return recorded, err