mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 06:02:13 +00:00
fix(multiplayer): persist arena identity on allocations
This commit is contained in:
+1
-1
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func (c Client) RecoverAllocation(ctx context.Context, request domain.Allocation
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AllocatedServer{}, false, err
|
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
|
found = true
|
||||||
}
|
}
|
||||||
return recovered, found, nil
|
return recovered, found, nil
|
||||||
@@ -291,7 +291,7 @@ func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AllocatedServer{}, err
|
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) {
|
func (c Client) endpoint() (string, error) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type Allocation struct {
|
|||||||
Region string
|
Region string
|
||||||
Build string
|
Build string
|
||||||
Protocol int
|
Protocol int
|
||||||
|
ArenaPath string
|
||||||
Transport string
|
Transport string
|
||||||
State ServerLifecycle
|
State ServerLifecycle
|
||||||
AllocatedAt time.Time
|
AllocatedAt time.Time
|
||||||
@@ -104,7 +105,7 @@ func (a *Allocator) Allocate(request AllocationRequest, now time.Time) (Allocati
|
|||||||
server := a.servers[ids[0]]
|
server := a.servers[ids[0]]
|
||||||
server.State = ServerAllocated
|
server.State = ServerAllocated
|
||||||
a.servers[server.ServerID] = server
|
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.allocations[request.AllocationID] = allocation
|
||||||
a.requestHashes[request.AllocationID] = digest
|
a.requestHashes[request.AllocationID] = digest
|
||||||
return allocation, nil
|
return allocation, nil
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE allocations
|
||||||
|
DROP COLUMN IF EXISTS arena_path;
|
||||||
@@ -8,6 +8,7 @@ SQL = (Path(__file__).parent / "0001_initial.sql").read_text()
|
|||||||
ASSIGNMENTS_SQL = (Path(__file__).parent / "0002_assignments.sql").read_text()
|
ASSIGNMENTS_SQL = (Path(__file__).parent / "0002_assignments.sql").read_text()
|
||||||
QUOTAS_SQL = (Path(__file__).parent / "0007_allocation_quotas.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()
|
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):
|
class MigrationTest(unittest.TestCase):
|
||||||
@@ -68,6 +69,10 @@ class MigrationTest(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertIn(fragment, ARENAS_SQL)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func FindProviderAllocation(ctx context.Context, db *sql.DB, request domain.Allo
|
|||||||
}
|
}
|
||||||
var allocation domain.Allocation
|
var allocation domain.Allocation
|
||||||
var digest []byte
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return domain.Allocation{}, false, nil
|
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
|
return domain.Allocation{}, false, err
|
||||||
}
|
}
|
||||||
want := allocationRequestDigest(request)
|
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
|
return domain.Allocation{}, false, domain.ErrConflict
|
||||||
}
|
}
|
||||||
allocation.State = domain.ServerAllocated
|
allocation.State = domain.ServerAllocated
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ WHERE server_id = (
|
|||||||
RETURNING server_id`
|
RETURNING server_id`
|
||||||
|
|
||||||
const InsertAllocationSQL = `INSERT INTO allocations
|
const InsertAllocationSQL = `INSERT INTO allocations
|
||||||
(allocation_id, match_id, server_id, region, build, protocol_version, transport, request_digest, state, allocated_at)
|
(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, 'ALLOCATED', $9)`
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'ALLOCATED', $10)`
|
||||||
|
|
||||||
const SelectAllocationSQL = `SELECT allocation_id, match_id, server_id, region, build,
|
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`
|
FROM allocations WHERE allocation_id = $1`
|
||||||
|
|
||||||
const ProviderServerClaimSQL = `UPDATE game_servers SET state = 'ALLOCATED', updated_at = $6
|
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 {
|
err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
var prior domain.Allocation
|
var prior domain.Allocation
|
||||||
var priorDigest []byte
|
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 err == nil {
|
||||||
if !bytes.Equal(priorDigest, digest[:]) {
|
if !bytes.Equal(priorDigest, digest[:]) {
|
||||||
return domain.ErrConflict
|
return domain.ErrConflict
|
||||||
@@ -85,8 +85,8 @@ func ClaimAllocation(ctx context.Context, db *sql.DB, request domain.AllocationR
|
|||||||
}
|
}
|
||||||
return err
|
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}
|
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.Transport, digest[:], 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 err
|
||||||
})
|
})
|
||||||
return allocation, 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
|
// Agones has already selected the server; no client-facing assignment may use
|
||||||
// the result until this exact tuple is durably recorded.
|
// 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) {
|
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 == "" {
|
if !validAllocationInput(db, request, now) || allocation.State != domain.ServerAllocated || allocation.ServerID == "" {
|
||||||
return domain.Allocation{}, domain.ErrAllocationInput
|
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 {
|
err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
var prior domain.Allocation
|
var prior domain.Allocation
|
||||||
var priorDigest []byte
|
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 err == nil {
|
||||||
if !bytes.Equal(priorDigest, digest[:]) || prior.ServerID != allocation.ServerID {
|
if !bytes.Equal(priorDigest, digest[:]) || prior.ServerID != allocation.ServerID {
|
||||||
return domain.ErrConflict
|
return domain.ErrConflict
|
||||||
@@ -133,7 +133,7 @@ func RecordProviderAllocation(ctx context.Context, db *sql.DB, allocation domain
|
|||||||
}
|
}
|
||||||
recorded = allocation
|
recorded = allocation
|
||||||
recorded.AllocatedAt = now
|
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 err
|
||||||
})
|
})
|
||||||
return recorded, err
|
return recorded, err
|
||||||
|
|||||||
Reference in New Issue
Block a user