From a0979f4953e49638761088a83863c41ebab78231 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 11:07:51 -0500 Subject: [PATCH] perf: parallelize independent API calls (#90) Closes #90 Replace sequential awaits with asyncio.gather() in all locations identified in the issue: - commands/gameplay/scorebug.py: parallel team lookups in publish_scorecard and scorebug commands; also fix missing await on async scorecard_tracker calls - commands/league/submit_scorecard.py: parallel away/home team lookups - tasks/live_scorebug_tracker.py: parallel team lookups inside per-scorecard loop (compounds across multiple active games); fix missing await on get_all_scorecards - commands/injuries/management.py: parallel get_current_state() + search_players() in injury_roll, injury_set_new, and injury_clear - services/trade_builder.py: parallel per-participant roster validation in validate_trade() Co-Authored-By: Claude Sonnet 4.6 --- commands/gameplay/scorebug.py | 43 +++++++++++++++++++---------- commands/injuries/management.py | 37 +++++++++++-------------- commands/league/submit_scorecard.py | 23 +++++++++------ services/trade_builder.py | 21 ++++++++++---- tasks/live_scorebug_tracker.py | 13 ++++----- 5 files changed, 80 insertions(+), 57 deletions(-) diff --git a/commands/gameplay/scorebug.py b/commands/gameplay/scorebug.py index dee4780..114e48f 100644 --- a/commands/gameplay/scorebug.py +++ b/commands/gameplay/scorebug.py @@ -4,6 +4,7 @@ Scorebug Commands Implements commands for publishing and displaying live game scorebugs from Google Sheets scorecards. """ +import asyncio import discord from discord.ext import commands from discord import app_commands @@ -73,12 +74,18 @@ class ScorebugCommands(commands.Cog): return # Get team data for display - away_team = None - home_team = None - if scorebug_data.away_team_id: - away_team = await team_service.get_team(scorebug_data.away_team_id) - if scorebug_data.home_team_id: - home_team = await team_service.get_team(scorebug_data.home_team_id) + away_team, home_team = await asyncio.gather( + ( + team_service.get_team(scorebug_data.away_team_id) + if scorebug_data.away_team_id + else asyncio.sleep(0) + ), + ( + team_service.get_team(scorebug_data.home_team_id) + if scorebug_data.home_team_id + else asyncio.sleep(0) + ), + ) # Format scorecard link away_abbrev = away_team.abbrev if away_team else "AWAY" @@ -86,7 +93,7 @@ class ScorebugCommands(commands.Cog): scorecard_link = f"[{away_abbrev} @ {home_abbrev}]({url})" # Store the scorecard in the tracker - self.scorecard_tracker.publish_scorecard( + await self.scorecard_tracker.publish_scorecard( text_channel_id=interaction.channel_id, # type: ignore sheet_url=url, publisher_id=interaction.user.id, @@ -157,7 +164,7 @@ class ScorebugCommands(commands.Cog): await interaction.response.defer(ephemeral=True) # Check if a scorecard is published in this channel - sheet_url = self.scorecard_tracker.get_scorecard(interaction.channel_id) # type: ignore + sheet_url = await self.scorecard_tracker.get_scorecard(interaction.channel_id) # type: ignore if not sheet_url: embed = EmbedTemplate.error( @@ -179,12 +186,18 @@ class ScorebugCommands(commands.Cog): ) # Get team data - away_team = None - home_team = None - if scorebug_data.away_team_id: - away_team = await team_service.get_team(scorebug_data.away_team_id) - if scorebug_data.home_team_id: - home_team = await team_service.get_team(scorebug_data.home_team_id) + away_team, home_team = await asyncio.gather( + ( + team_service.get_team(scorebug_data.away_team_id) + if scorebug_data.away_team_id + else asyncio.sleep(0) + ), + ( + team_service.get_team(scorebug_data.home_team_id) + if scorebug_data.home_team_id + else asyncio.sleep(0) + ), + ) # Create scorebug embed using shared utility embed = create_scorebug_embed( @@ -194,7 +207,7 @@ class ScorebugCommands(commands.Cog): await interaction.edit_original_response(content=None, embed=embed) # Update timestamp in tracker - self.scorecard_tracker.update_timestamp(interaction.channel_id) # type: ignore + await self.scorecard_tracker.update_timestamp(interaction.channel_id) # type: ignore except SheetsException as e: embed = EmbedTemplate.error( diff --git a/commands/injuries/management.py b/commands/injuries/management.py index d43802b..decf251 100644 --- a/commands/injuries/management.py +++ b/commands/injuries/management.py @@ -11,6 +11,7 @@ The injury rating format (#p##) encodes both games played and rating: - Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20) """ +import asyncio import math import random import discord @@ -114,16 +115,14 @@ class InjuryGroup(app_commands.Group): """Roll for injury using 3d6 dice and injury tables.""" await interaction.response.defer() - # Get current season - current = await league_service.get_current_state() + # Get current season and search for player in parallel + current, players = await asyncio.gather( + league_service.get_current_state(), + player_service.search_players(player_name, limit=10), + ) if not current: raise BotException("Failed to get current season information") - # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players( - player_name, limit=10, season=current.season - ) - if not players: embed = EmbedTemplate.error( title="Player Not Found", @@ -530,16 +529,14 @@ class InjuryGroup(app_commands.Group): await interaction.followup.send(embed=embed, ephemeral=True) return - # Get current season - current = await league_service.get_current_state() + # Get current season and search for player in parallel + current, players = await asyncio.gather( + league_service.get_current_state(), + player_service.search_players(player_name, limit=10), + ) if not current: raise BotException("Failed to get current season information") - # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players( - player_name, limit=10, season=current.season - ) - if not players: embed = EmbedTemplate.error( title="Player Not Found", @@ -717,16 +714,14 @@ class InjuryGroup(app_commands.Group): await interaction.response.defer() - # Get current season - current = await league_service.get_current_state() + # Get current season and search for player in parallel + current, players = await asyncio.gather( + league_service.get_current_state(), + player_service.search_players(player_name, limit=10), + ) if not current: raise BotException("Failed to get current season information") - # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players( - player_name, limit=10, season=current.season - ) - if not players: embed = EmbedTemplate.error( title="Player Not Found", diff --git a/commands/league/submit_scorecard.py b/commands/league/submit_scorecard.py index caa2985..d416a8b 100644 --- a/commands/league/submit_scorecard.py +++ b/commands/league/submit_scorecard.py @@ -5,6 +5,7 @@ Implements the /submit-scorecard command for submitting Google Sheets scorecards with play-by-play data, pitching decisions, and game results. """ +import asyncio from typing import Optional, List import discord @@ -107,11 +108,13 @@ class SubmitScorecardCommands(commands.Cog): content="🔍 Looking up teams and managers..." ) - away_team = await team_service.get_team_by_abbrev( - setup_data["away_team_abbrev"], current.season - ) - home_team = await team_service.get_team_by_abbrev( - setup_data["home_team_abbrev"], current.season + away_team, home_team = await asyncio.gather( + team_service.get_team_by_abbrev( + setup_data["away_team_abbrev"], current.season + ), + team_service.get_team_by_abbrev( + setup_data["home_team_abbrev"], current.season + ), ) if not away_team or not home_team: @@ -235,9 +238,13 @@ class SubmitScorecardCommands(commands.Cog): decision["game_num"] = setup_data["game_num"] # Validate WP and LP exist and fetch Player objects - wp, lp, sv, holders, _blown_saves = ( - await decision_service.find_winning_losing_pitchers(decisions_data) - ) + ( + wp, + lp, + sv, + holders, + _blown_saves, + ) = await decision_service.find_winning_losing_pitchers(decisions_data) if wp is None or lp is None: await interaction.edit_original_response( diff --git a/services/trade_builder.py b/services/trade_builder.py index aa05b37..26f61aa 100644 --- a/services/trade_builder.py +++ b/services/trade_builder.py @@ -4,6 +4,7 @@ Trade Builder Service Extends the TransactionBuilder to support multi-team trades and player exchanges. """ +import asyncio import logging from typing import Dict, List, Optional, Set from datetime import datetime, timezone @@ -524,14 +525,22 @@ class TradeBuilder: # Validate each team's roster after the trade for participant in self.trade.participants: - team_id = participant.team.id - result.team_abbrevs[team_id] = participant.team.abbrev - if team_id in self._team_builders: - builder = self._team_builders[team_id] - roster_validation = await builder.validate_transaction(next_week) + result.team_abbrevs[participant.team.id] = participant.team.abbrev + team_ids_to_validate = [ + participant.team.id + for participant in self.trade.participants + if participant.team.id in self._team_builders + ] + if team_ids_to_validate: + validations = await asyncio.gather( + *[ + self._team_builders[tid].validate_transaction(next_week) + for tid in team_ids_to_validate + ] + ) + for team_id, roster_validation in zip(team_ids_to_validate, validations): result.participant_validations[team_id] = roster_validation - if not roster_validation.is_legal: result.is_legal = False diff --git a/tasks/live_scorebug_tracker.py b/tasks/live_scorebug_tracker.py index 9013ac2..ab81581 100644 --- a/tasks/live_scorebug_tracker.py +++ b/tasks/live_scorebug_tracker.py @@ -95,7 +95,7 @@ class LiveScorebugTracker: # Don't return - still update voice channels else: # Get all published scorecards - all_scorecards = self.scorecard_tracker.get_all_scorecards() + all_scorecards = await self.scorecard_tracker.get_all_scorecards() if not all_scorecards: # No active scorebugs - clear the channel and hide it @@ -112,17 +112,16 @@ class LiveScorebugTracker: for text_channel_id, sheet_url in all_scorecards: try: scorebug_data = await self.scorebug_service.read_scorebug_data( - sheet_url, full_length=False # Compact view for live channel + sheet_url, + full_length=False, # Compact view for live channel ) # Only include active (non-final) games if scorebug_data.is_active: # Get team data - away_team = await team_service.get_team( - scorebug_data.away_team_id - ) - home_team = await team_service.get_team( - scorebug_data.home_team_id + away_team, home_team = await asyncio.gather( + team_service.get_team(scorebug_data.away_team_id), + team_service.get_team(scorebug_data.home_team_id), ) if away_team is None or home_team is None: