package store import ( "context" "database/sql" "fmt" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) const DueSeasonRolloversSQL = `SELECT s.season_id, r.player_id, r.rating, r.deviation, r.volatility, r.ranked_games FROM seasons s CROSS JOIN ratings r LEFT JOIN ranked_season_rollovers rr ON rr.season_id = s.season_id AND rr.player_id = r.player_id WHERE s.playlist = 'ranked' AND s.ends_at <= $1 AND rr.player_id IS NULL ORDER BY s.ends_at, s.season_id, r.player_id LIMIT $2` const MarkSeasonRolledOverSQL = `UPDATE seasons SET rolled_over_at = $2 WHERE season_id = $1 AND rolled_over_at IS NULL AND NOT EXISTS (SELECT 1 FROM ratings r 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 profile domain.RankedProfile } // RolloverDueSeasons processes a bounded batch. Each player update is its own // exactly-once SERIALIZABLE transaction, so a worker crash can safely resume. func RolloverDueSeasons(ctx context.Context, db *sql.DB, now time.Time, limit int) (int, error) { 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 } defer rows.Close() var due []dueSeasonRollover for rows.Next() { var item dueSeasonRollover if err := rows.Scan(&item.seasonID, &item.playerID, &item.profile.Value, &item.profile.RD, &item.profile.Volatility, &item.profile.RankedGames); err != nil { return 0, err } due = append(due, item) } if err := rows.Err(); err != nil { return 0, err } count := 0 for _, item := range due { if _, applied, err := ApplyRankedSeasonRollover(ctx, db, item.playerID, item.seasonID, item.profile, now); err != nil { return count, err } else if applied { count++ } if _, err := db.ExecContext(ctx, MarkSeasonRolledOverSQL, item.seasonID, now); err != nil { return count, err } } return count, nil }