package store import ( "context" "database/sql" "fmt" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) const SeasonRatingLockSQL = `SELECT player_id, rating, deviation, volatility, ranked_games, revision FROM ratings WHERE player_id = $1 FOR UPDATE` const SeasonRolloverInsertSQL = `INSERT INTO ranked_season_rollovers (player_id, season_id, rating, deviation, volatility, ranked_games, rolled_over_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (player_id, season_id) DO NOTHING` const SeasonRatingUpdateSQL = `UPDATE ratings SET rating = $2, deviation = $3, volatility = $4, revision = revision + 1, updated_at = $5 WHERE player_id = $1` // ApplyRankedSeasonRollover persists the domain rollover exactly once. The // marker insert and rating update share one SERIALIZABLE transaction, so a // retry after a worker failure cannot apply compression twice or leave a // marker without its corresponding rating snapshot. func ApplyRankedSeasonRollover(ctx context.Context, db *sql.DB, playerID, seasonID string, profile domain.RankedProfile, now time.Time) (domain.RankedProfile, bool, error) { if db == nil || playerID == "" || seasonID == "" || now.IsZero() { return domain.RankedProfile{}, false, fmt.Errorf("invalid season rollover arguments") } // The caller's profile is only a validation-compatible hint. The durable // row is authoritative because a result update may have committed after the // caller read its snapshot but before this transaction acquired the lock. _ = profile var updated domain.RankedProfile applied := false err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { var locked domain.RankedProfile var lockedPlayerID string var revision int64 if err := tx.QueryRowContext(ctx, SeasonRatingLockSQL, playerID).Scan(&lockedPlayerID, &locked.Value, &locked.RD, &locked.Volatility, &locked.RankedGames, &revision); err != nil { return err } if lockedPlayerID != playerID { return fmt.Errorf("locked unexpected rating row") } var err error updated, _, err = domain.ApplySeasonRollover(locked, seasonID) if err != nil { return err } result, err := tx.ExecContext(ctx, SeasonRolloverInsertSQL, playerID, seasonID, updated.Value, updated.RD, updated.Volatility, updated.RankedGames, now) if err != nil { return err } changed, err := result.RowsAffected() if err != nil { return err } if changed == 0 { updated = locked return nil } if _, err := tx.ExecContext(ctx, SeasonRatingUpdateSQL, playerID, updated.Value, updated.RD, updated.Volatility, now); err != nil { return err } applied = true return nil }) if err != nil { return domain.RankedProfile{}, false, err } return updated, applied, nil }