From 96f311c129e53765d3cb7fcc10f954f67461d403 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:30:23 +0100 Subject: [PATCH] fix(multiplayer): finalize empty ranked seasons --- multiplayer-next.md | 2 ++ server/store/maintenance_sql.go | 12 ++++++++++++ server/store/maintenance_sql_test.go | 5 +++++ server/store/postgres_integration_test.go | 20 ++++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/multiplayer-next.md b/multiplayer-next.md index 863f23db..b576f956 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1451,6 +1451,8 @@ The same allocation path now carries the matcher-selected playlist, preventing a The allocator’s durable bind now increments the match revision and writes a participant-targeted `state_changed(ALLOCATING)` outbox event in the same serializable transaction as the server binding and ticket transitions, so clients can recover allocation progress after a delivery outage. +Ranked maintenance now marks expired seasons with no ranked profiles as rolled over, preventing an empty season from being selected and reconsidered on every maintenance pass; the boundary is covered by the integration-tag regression suite. + 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, migration 0009 retains it on provider allocations, domain/store/provider boundaries and recovery lookups recheck 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 recovery worker also rejects a provider-recovered allocation whose arena differs from the durable request before recording or binding it. diff --git a/server/store/maintenance_sql.go b/server/store/maintenance_sql.go index c5ef1fcc..948c4ae2 100644 --- a/server/store/maintenance_sql.go +++ b/server/store/maintenance_sql.go @@ -24,6 +24,12 @@ WHERE season_id = $1 AND rolled_over_at IS NULL LEFT JOIN ranked_season_rollovers rr ON rr.season_id = $1 AND rr.player_id = r.player_id WHERE rr.player_id IS NULL)` +const MarkEmptySeasonsSQL = `UPDATE seasons s SET rolled_over_at = $1 +WHERE s.playlist = 'ranked' AND s.ends_at <= $1 AND s.rolled_over_at IS NULL + AND NOT EXISTS (SELECT 1 FROM ratings r + LEFT JOIN ranked_season_rollovers rr ON rr.season_id = s.season_id AND rr.player_id = r.player_id + WHERE rr.player_id IS NULL)` + type dueSeasonRollover struct { seasonID string playerID string @@ -36,6 +42,12 @@ func RolloverDueSeasons(ctx context.Context, db *sql.DB, now time.Time, limit in if db == nil || now.IsZero() || limit < 1 || limit > 1000 { return 0, fmt.Errorf("invalid season maintenance arguments") } + // A season with no ranked profiles has no player row for DueSeasonRolloversSQL + // to return. Mark it here so maintenance remains idempotent instead of + // reconsidering the same empty season on every pass. + if _, err := db.ExecContext(ctx, MarkEmptySeasonsSQL, now); err != nil { + return 0, err + } rows, err := db.QueryContext(ctx, DueSeasonRolloversSQL, now, limit) if err != nil { return 0, err diff --git a/server/store/maintenance_sql_test.go b/server/store/maintenance_sql_test.go index e563a7a4..2e7eb31b 100644 --- a/server/store/maintenance_sql_test.go +++ b/server/store/maintenance_sql_test.go @@ -16,6 +16,11 @@ func TestMaintenanceSQLEnumeratesOnlyUnrolledRankedPlayers(t *testing.T) { t.Fatalf("mark query missing %q", fragment) } } + for _, fragment := range []string{"s.playlist = 'ranked'", "s.ends_at <= $1", "s.rolled_over_at IS NULL", "NOT EXISTS", "ranked_season_rollovers"} { + if !contains(MarkEmptySeasonsSQL, fragment) { + t.Fatalf("empty-season query missing %q", fragment) + } + } } func TestRolloverDueSeasonsRejectsUnboundedMaintenance(t *testing.T) { diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index 450877dc..ab40db48 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -1255,6 +1255,26 @@ func TestPostgreSQLRankedSeasonRolloverIsExactlyOnce(t *testing.T) { } } +func TestPostgreSQLEmptyRankedSeasonIsMarkedRolledOver(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + now := time.Now().UTC().Truncate(time.Microsecond) + ctx := context.Background() + if _, err := db.ExecContext(ctx, `INSERT INTO seasons (season_id, playlist, starts_at, ends_at) VALUES ('empty-season', 'ranked', $1, $2)`, now.Add(-12*7*24*time.Hour), now); err != nil { + t.Fatal(err) + } + if count, err := RolloverDueSeasons(ctx, db, now, 100); err != nil || count != 0 { + t.Fatalf("empty-season maintenance count=%d err=%v", count, err) + } + var rolledAt sql.NullTime + if err := db.QueryRowContext(ctx, `SELECT rolled_over_at FROM seasons WHERE season_id = 'empty-season'`).Scan(&rolledAt); err != nil { + t.Fatal(err) + } + if !rolledAt.Valid { + t.Fatal("empty ranked season was not marked rolled over") + } +} + func TestPostgreSQLMigrationsAreForwardExecutable(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db)