fix(multiplayer): finalize empty ranked seasons

This commit is contained in:
Josh Creek
2026-09-01 21:30:23 +01:00
parent 836cedec3c
commit 96f311c129
4 changed files with 39 additions and 0 deletions
+12
View File
@@ -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
+5
View File
@@ -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) {
+20
View File
@@ -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)