fix(multiplayer): propagate allocated playlist

This commit is contained in:
Josh Creek
2026-09-01 17:00:37 +01:00
parent 8bd1455a2c
commit 9ae01ecc5a
8 changed files with 24 additions and 6 deletions
+2
View File
@@ -1436,3 +1436,5 @@ The workload-authenticated `POST /servers/{serverId}/shutdown` contract is now e
The allocated supervisor now calls that shutdown acknowledgment during signal-bound controlled drain, using the same workload credential and a deterministic idempotency key after the local drain request succeeds. The lifecycle test verifies the drain-before-ack ordering, credential separation, and bounded graceful child exit; live pod termination and control-plane outage behavior remain deployment gates. The allocated supervisor now calls that shutdown acknowledgment during signal-bound controlled drain, using the same workload credential and a deterministic idempotency key after the local drain request succeeds. The lifecycle test verifies the drain-before-ack ordering, credential separation, and bounded graceful child exit; live pod termination and control-plane outage behavior remain deployment gates.
Allocator-selected region, build, protocol, and transport now travel with the allocation as Agones annotations and override stale child launch flags immediately before an allocated process starts. The overlay rejects control characters and preserves direct-server command behavior; focused supervisor/allocator tests cover precedence and annotation payloads, while live Agones passthrough remains an infrastructure gate. Allocator-selected region, build, protocol, and transport now travel with the allocation as Agones annotations and override stale child launch flags immediately before an allocated process starts. The overlay rejects control characters and preserves direct-server command behavior; focused supervisor/allocator tests cover precedence and annotation payloads, while live Agones passthrough remains an infrastructure gate.
The same allocation path now carries the matcher-selected playlist, preventing a ranked match from inheriting the Fleets casual default. Durable allocation claims return the playlist, the worker includes it in Fleet selection metadata, Agones copies it to the allocated GameServer, and the supervisor overrides `--playlist` before launch; the existing compatibility tests remain green.
+3
View File
@@ -177,6 +177,9 @@ func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest,
"cosmic-clash.io/protocol": strconv.Itoa(request.Protocol), "cosmic-clash.io/protocol": strconv.Itoa(request.Protocol),
"cosmic-clash.io/transport": request.Transport, "cosmic-clash.io/transport": request.Transport,
} }
if playlist := labels["cosmic-clash.io/playlist"]; playlist == string(domain.Casual) || playlist == string(domain.Ranked) {
body.Spec.Metadata.Annotations["cosmic-clash.io/playlist"] = playlist
}
if len(c.WorkloadSecret) > 0 { if len(c.WorkloadSecret) > 0 {
ttl := c.WorkloadTokenTTL ttl := c.WorkloadTokenTTL
if ttl <= 0 { if ttl <= 0 {
+5 -1
View File
@@ -59,10 +59,14 @@ func (w Worker) RunOnce(ctx context.Context) (bool, error) {
// template. They are derived only from the durable match plan, never client // template. They are derived only from the durable match plan, never client
// input or mutable worker configuration. // input or mutable worker configuration.
func AllocationLabels(request domain.AllocationRequest) map[string]string { func AllocationLabels(request domain.AllocationRequest) map[string]string {
return map[string]string{ labels := map[string]string{
"cosmic-clash.io/region": request.Region, "cosmic-clash.io/region": request.Region,
"cosmic-clash.io/build": request.Build, "cosmic-clash.io/build": request.Build,
"cosmic-clash.io/protocol": strconv.Itoa(request.Protocol), "cosmic-clash.io/protocol": strconv.Itoa(request.Protocol),
"cosmic-clash.io/transport": request.Transport, "cosmic-clash.io/transport": request.Transport,
} }
if request.Playlist != "" {
labels["cosmic-clash.io/playlist"] = string(request.Playlist)
}
return labels
} }
+7
View File
@@ -85,3 +85,10 @@ func TestAllocationLabelsMirrorFleetCompatibilityTuple(t *testing.T) {
t.Fatalf("labels=%v want=%v", got, want) t.Fatalf("labels=%v want=%v", got, want)
} }
} }
func TestAllocationLabelsCarryPlaylistWhenKnown(t *testing.T) {
labels := AllocationLabels(domain.AllocationRequest{Playlist: domain.Ranked, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"})
if labels["cosmic-clash.io/playlist"] != string(domain.Ranked) {
t.Fatalf("playlist label = %q, want %q", labels["cosmic-clash.io/playlist"], domain.Ranked)
}
}
+1
View File
@@ -27,6 +27,7 @@ type ReadyServer struct {
type AllocationRequest struct { type AllocationRequest struct {
AllocationID string AllocationID string
MatchID string MatchID string
Playlist Playlist
Region string Region string
Build string Build string
Protocol int Protocol int
+4 -4
View File
@@ -30,7 +30,7 @@ UPDATE matches m
SET allocation_id = 'allocation-' || candidate.match_id, allocation_claimed_at = $2 SET allocation_id = 'allocation-' || candidate.match_id, allocation_claimed_at = $2
FROM candidate FROM candidate
WHERE m.match_id = candidate.match_id WHERE m.match_id = candidate.match_id
RETURNING m.match_id, m.region, m.protocol_version, m.allocation_id` RETURNING m.match_id, m.playlist, m.region, m.protocol_version, m.allocation_id`
const AllocatingMatchBuildSQL = `SELECT client_build const AllocatingMatchBuildSQL = `SELECT client_build
FROM queue_tickets q FROM queue_tickets q
@@ -200,10 +200,10 @@ func ClaimAllocatingMatch(ctx context.Context, db *sql.DB, transport string, now
var item PendingAllocation var item PendingAllocation
found := false found := false
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 matchID, region string var matchID, playlist, region string
var protocol int var protocol int
var claimedID string var claimedID string
err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &region, &protocol, &claimedID) err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &playlist, &region, &protocol, &claimedID)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil return nil
} }
@@ -233,7 +233,7 @@ func ClaimAllocatingMatch(ctx context.Context, db *sql.DB, transport string, now
if build == "" { if build == "" {
return fmt.Errorf("allocating match has no participants") return fmt.Errorf("allocating match has no participants")
} }
item.Request = domain.AllocationRequest{AllocationID: claimedID, MatchID: matchID, Region: region, Build: build, Protocol: protocol, Transport: transport} item.Request = domain.AllocationRequest{AllocationID: claimedID, MatchID: matchID, Playlist: domain.Playlist(playlist), Region: region, Build: build, Protocol: protocol, Transport: transport}
found = true found = true
return nil return nil
}) })
+1 -1
View File
@@ -9,7 +9,7 @@ import (
func TestAllocationMatchClaimSQLFencesConcurrentWorkers(t *testing.T) { func TestAllocationMatchClaimSQLFencesConcurrentWorkers(t *testing.T) {
checks := map[string][]string{ checks := map[string][]string{
ClaimAllocatingMatchSQL: {"FOR UPDATE SKIP LOCKED", "allocation_id = 'allocation-' || candidate.match_id", "allocation_claimed_at <= $1", "ORDER BY created_at, match_id"}, ClaimAllocatingMatchSQL: {"FOR UPDATE SKIP LOCKED", "allocation_id = 'allocation-' || candidate.match_id", "allocation_claimed_at <= $1", "ORDER BY created_at, match_id", "m.playlist"},
AllocatingMatchBuildSQL: {"match_participants", "queue_tickets", "ORDER BY q.client_build"}, AllocatingMatchBuildSQL: {"match_participants", "queue_tickets", "ORDER BY q.client_build"},
BindAllocatedMatchParticipantsSQL: {"allocation_id = $2", "server_id IS NULL", "SET server_id = $3", "FROM allocations", "state = 'ALLOCATING'", "revision = revision + 1"}, BindAllocatedMatchParticipantsSQL: {"allocation_id = $2", "server_id IS NULL", "SET server_id = $3", "FROM allocations", "state = 'ALLOCATING'", "revision = revision + 1"},
ReleaseAllocatedMatchClaimSQL: {"allocation_id = $2", "allocation_id = NULL", "allocation_claimed_at = NULL"}, ReleaseAllocatedMatchClaimSQL: {"allocation_id = $2", "allocation_id = NULL", "allocation_claimed_at = NULL"},
+1
View File
@@ -343,6 +343,7 @@ func withAllocatedCompatibility(command []string, gameServer GameServer) ([]stri
return command, nil return command, nil
} }
for annotation, flag := range map[string]string{ for annotation, flag := range map[string]string{
"cosmic-clash.io/playlist": "playlist",
"cosmic-clash.io/region": "region", "cosmic-clash.io/region": "region",
"cosmic-clash.io/build": "client-build", "cosmic-clash.io/build": "client-build",
"cosmic-clash.io/protocol": "protocol-version", "cosmic-clash.io/protocol": "protocol-version",