diff --git a/api_service/api_server.py b/api_service/api_server.py index adf1dd6..3e12285 100644 --- a/api_service/api_server.py +++ b/api_service/api_server.py @@ -428,14 +428,41 @@ try: # Try relative import first try: from .cog_management_endpoints import router as cog_management_router - except ImportError: + log.info("Successfully imported cog_management_endpoints via relative import") + except ImportError as e: + log.warning(f"Relative import of cog_management_endpoints failed: {e}") # Fall back to absolute import - from cog_management_endpoints import router as cog_management_router + try: + from cog_management_endpoints import router as cog_management_router + log.info("Successfully imported cog_management_endpoints via absolute import") + except ImportError as e2: + log.error(f"Both import attempts for cog_management_endpoints failed: {e2}") + # Try to import the module directly to see what's available + try: + import sys + log.info(f"Python path: {sys.path}") + # Try to find the module in the current directory + import os + current_dir = os.path.dirname(os.path.abspath(__file__)) + log.info(f"Current directory: {current_dir}") + files = os.listdir(current_dir) + log.info(f"Files in current directory: {files}") + + # Try to import the module with a full path + sys.path.append(current_dir) + import cog_management_endpoints + log.info(f"Successfully imported cog_management_endpoints module") + router = cog_management_endpoints.router + log.info(f"Successfully got router from cog_management_endpoints") + cog_management_router = router + except Exception as e3: + log.error(f"Failed to import cog_management_endpoints module: {e3}") + raise e2 # Add the cog management router to the dashboard API app dashboard_api_app.include_router(cog_management_router, tags=["Cog Management"]) log.info("Cog management endpoints loaded successfully") -except ImportError as e: +except Exception as e: log.error(f"Could not import cog management endpoints: {e}") log.error("Cog management endpoints will not be available") diff --git a/api_service/dashboard_api_endpoints.py b/api_service/dashboard_api_endpoints.py index ad3e34a..d1920dc 100644 --- a/api_service/dashboard_api_endpoints.py +++ b/api_service/dashboard_api_endpoints.py @@ -459,36 +459,78 @@ async def get_guild_settings( detail="Settings manager not available" ) - # Get prefix - prefix = await settings_manager.get_guild_prefix(guild_id, DEFAULT_PREFIX) - - # Get welcome/goodbye settings - welcome_channel_id = await settings_manager.get_setting(guild_id, 'welcome_channel_id') - welcome_message = await settings_manager.get_setting(guild_id, 'welcome_message') - goodbye_channel_id = await settings_manager.get_setting(guild_id, 'goodbye_channel_id') - goodbye_message = await settings_manager.get_setting(guild_id, 'goodbye_message') - - # Get cog enabled statuses - cogs_enabled = await settings_manager.get_all_enabled_cogs(guild_id) - - # Get command enabled statuses - commands_enabled = await settings_manager.get_all_enabled_commands(guild_id) - - # Construct response + # Initialize settings with defaults settings = { - "prefix": prefix, - "welcome_channel_id": welcome_channel_id, - "welcome_message": welcome_message, - "goodbye_channel_id": goodbye_channel_id, - "goodbye_message": goodbye_message, - "cogs": cogs_enabled, - "commands": commands_enabled + "prefix": DEFAULT_PREFIX, + "welcome_channel_id": None, + "welcome_message": None, + "goodbye_channel_id": None, + "goodbye_message": None, + "cogs": {}, + "commands": {} } + # Get prefix with error handling + try: + settings["prefix"] = await settings_manager.get_guild_prefix(guild_id, DEFAULT_PREFIX) + except Exception as e: + log.warning(f"Error getting prefix for guild {guild_id}, using default: {e}") + # Keep default prefix + + # Get welcome/goodbye settings with error handling + try: + settings["welcome_channel_id"] = await settings_manager.get_setting(guild_id, 'welcome_channel_id') + except Exception as e: + log.warning(f"Error getting welcome_channel_id for guild {guild_id}: {e}") + + try: + settings["welcome_message"] = await settings_manager.get_setting(guild_id, 'welcome_message') + except Exception as e: + log.warning(f"Error getting welcome_message for guild {guild_id}: {e}") + + try: + settings["goodbye_channel_id"] = await settings_manager.get_setting(guild_id, 'goodbye_channel_id') + except Exception as e: + log.warning(f"Error getting goodbye_channel_id for guild {guild_id}: {e}") + + try: + settings["goodbye_message"] = await settings_manager.get_setting(guild_id, 'goodbye_message') + except Exception as e: + log.warning(f"Error getting goodbye_message for guild {guild_id}: {e}") + + # Get cog enabled statuses with error handling + try: + settings["cogs"] = await settings_manager.get_all_enabled_cogs(guild_id) + except Exception as e: + log.warning(f"Error getting cog enabled statuses for guild {guild_id}: {e}") + # Keep empty dict for cogs + + # Get command enabled statuses with error handling + try: + settings["commands"] = await settings_manager.get_all_enabled_commands(guild_id) + except Exception as e: + log.warning(f"Error getting command enabled statuses for guild {guild_id}: {e}") + # Keep empty dict for commands + return settings except HTTPException: # Re-raise HTTP exceptions raise + except RuntimeError as e: + # Handle event loop errors specifically + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.error(f"Event loop error getting settings for guild {guild_id}: {e}") + # Return a more helpful error message + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Database connection error. Please try again." + ) + else: + log.error(f"Runtime error getting settings for guild {guild_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting settings: {str(e)}" + ) except Exception as e: log.error(f"Error getting settings for guild {guild_id}: {e}") raise HTTPException( @@ -824,8 +866,20 @@ async def update_global_settings( # --- Cog and Command Management Endpoints --- # Note: These endpoints have been moved to cog_management_endpoints.py -# --- Cog Management Redirect Endpoints --- -# These endpoints redirect to the cog management endpoints in cog_management_endpoints.py +# --- Cog Management Endpoints --- +# These endpoints provide direct implementation and fallback for cog management + +# Define models needed for cog management +class CogCommandInfo(BaseModel): + name: str + description: Optional[str] = None + enabled: bool = True + +class CogInfo(BaseModel): + name: str + description: Optional[str] = None + enabled: bool = True + commands: List[Dict[str, Any]] = [] @router.get("/guilds/{guild_id}/cogs", response_model=List[Any]) async def get_guild_cogs_redirect( @@ -833,18 +887,93 @@ async def get_guild_cogs_redirect( _user: dict = Depends(get_dashboard_user), _admin: bool = Depends(verify_dashboard_guild_admin) ): - """Redirect to the cog management endpoint.""" + """Get all cogs and their commands for a guild.""" try: - # Import the cog management endpoint + # First try to use the dedicated cog management endpoint try: + # Try relative import first from .cog_management_endpoints import get_guild_cogs - except ImportError: - from cog_management_endpoints import get_guild_cogs + log.info(f"Successfully imported get_guild_cogs via relative import") - # Call the cog management endpoint - return await get_guild_cogs(guild_id, _user, _admin) + # Call the cog management endpoint + log.info(f"Calling get_guild_cogs for guild {guild_id}") + result = await get_guild_cogs(guild_id, _user, _admin) + log.info(f"Successfully retrieved cogs for guild {guild_id}") + return result + except ImportError as e: + log.warning(f"Relative import failed: {e}, trying absolute import") + try: + # Fall back to absolute import + from cog_management_endpoints import get_guild_cogs + log.info(f"Successfully imported get_guild_cogs via absolute import") + + # Call the cog management endpoint + log.info(f"Calling get_guild_cogs for guild {guild_id}") + result = await get_guild_cogs(guild_id, _user, _admin) + log.info(f"Successfully retrieved cogs for guild {guild_id}") + return result + except ImportError as e2: + log.error(f"Both import attempts failed: {e2}") + log.warning("Falling back to direct implementation") + + # Fall back to direct implementation + # Check if bot instance is available via discord_bot_sync_api + try: + from discordbot import discord_bot_sync_api + bot = discord_bot_sync_api.bot_instance + if not bot: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot instance not available" + ) + except ImportError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot sync API not available" + ) + + # Get all cogs from the bot + cogs_list = [] + for cog_name, cog in bot.cogs.items(): + # Get enabled status from settings_manager + is_enabled = await settings_manager.is_cog_enabled(guild_id, cog_name, default_enabled=True) + + # Get commands for this cog + commands_list = [] + for command in cog.get_commands(): + # Get command enabled status + cmd_enabled = await settings_manager.is_command_enabled(guild_id, command.qualified_name, default_enabled=True) + commands_list.append({ + "name": command.qualified_name, + "description": command.help or "No description available", + "enabled": cmd_enabled + }) + + # Add slash commands if any + app_commands = [cmd for cmd in bot.tree.get_commands() if hasattr(cmd, 'cog') and cmd.cog and cmd.cog.qualified_name == cog_name] + for cmd in app_commands: + # Get command enabled status + cmd_enabled = await settings_manager.is_command_enabled(guild_id, cmd.name, default_enabled=True) + if not any(c["name"] == cmd.name for c in commands_list): # Avoid duplicates + commands_list.append({ + "name": cmd.name, + "description": cmd.description or "No description available", + "enabled": cmd_enabled + }) + + cogs_list.append(CogInfo( + name=cog_name, + description=cog.__doc__ or "No description available", + enabled=is_enabled, + commands=commands_list + )) + + return cogs_list + except HTTPException: + # Re-raise HTTP exceptions + raise except Exception as e: - log.error(f"Error redirecting to cog management endpoint: {e}") + log.error(f"Error getting cogs for guild {guild_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting cogs: {str(e)}" @@ -858,18 +987,84 @@ async def update_cog_status_redirect( _user: dict = Depends(get_dashboard_user), _admin: bool = Depends(verify_dashboard_guild_admin) ): - """Redirect to the cog management endpoint for updating cog status.""" + """Enable or disable a cog for a guild.""" try: - # Import the cog management endpoint + # First try to use the dedicated cog management endpoint try: + # Try relative import first from .cog_management_endpoints import update_cog_status - except ImportError: - from cog_management_endpoints import update_cog_status + log.info(f"Successfully imported update_cog_status via relative import") - # Call the cog management endpoint - return await update_cog_status(guild_id, cog_name, enabled, _user, _admin) + # Call the cog management endpoint + log.info(f"Calling update_cog_status for guild {guild_id}, cog {cog_name}, enabled={enabled}") + result = await update_cog_status(guild_id, cog_name, enabled, _user, _admin) + log.info(f"Successfully updated cog status for guild {guild_id}, cog {cog_name}") + return result + except ImportError as e: + log.warning(f"Relative import failed: {e}, trying absolute import") + try: + # Fall back to absolute import + from cog_management_endpoints import update_cog_status + log.info(f"Successfully imported update_cog_status via absolute import") + + # Call the cog management endpoint + log.info(f"Calling update_cog_status for guild {guild_id}, cog {cog_name}, enabled={enabled}") + result = await update_cog_status(guild_id, cog_name, enabled, _user, _admin) + log.info(f"Successfully updated cog status for guild {guild_id}, cog {cog_name}") + return result + except ImportError as e2: + log.error(f"Both import attempts failed: {e2}") + log.warning("Falling back to direct implementation") + + # Fall back to direct implementation + # Check if settings_manager is available + if not settings_manager or not settings_manager.pg_pool: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Settings manager not available" + ) + + # Check if the cog exists + try: + from discordbot import discord_bot_sync_api + bot = discord_bot_sync_api.bot_instance + if not bot: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot instance not available" + ) + + if cog_name not in bot.cogs: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Cog '{cog_name}' not found" + ) + + # Check if it's a core cog + core_cogs = getattr(bot, 'core_cogs', {'SettingsCog', 'HelpCog'}) + if cog_name in core_cogs: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Core cog '{cog_name}' cannot be disabled" + ) + except ImportError: + # If we can't import the bot, we'll just assume the cog exists + log.warning("Bot sync API not available, skipping cog existence check") + + # Update the cog enabled status + success = await settings_manager.set_cog_enabled(guild_id, cog_name, enabled) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update cog '{cog_name}' status" + ) + + return {"message": f"Cog '{cog_name}' {'enabled' if enabled else 'disabled'} successfully"} + except HTTPException: + # Re-raise HTTP exceptions + raise except Exception as e: - log.error(f"Error redirecting to cog management endpoint for updating cog status: {e}") + log.error(f"Error updating cog status for guild {guild_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error updating cog status: {str(e)}" @@ -883,18 +1078,81 @@ async def update_command_status_redirect( _user: dict = Depends(get_dashboard_user), _admin: bool = Depends(verify_dashboard_guild_admin) ): - """Redirect to the cog management endpoint for updating command status.""" + """Enable or disable a command for a guild.""" try: - # Import the cog management endpoint + # First try to use the dedicated cog management endpoint try: + # Try relative import first from .cog_management_endpoints import update_command_status - except ImportError: - from cog_management_endpoints import update_command_status + log.info(f"Successfully imported update_command_status via relative import") - # Call the cog management endpoint - return await update_command_status(guild_id, command_name, enabled, _user, _admin) + # Call the cog management endpoint + log.info(f"Calling update_command_status for guild {guild_id}, command {command_name}, enabled={enabled}") + result = await update_command_status(guild_id, command_name, enabled, _user, _admin) + log.info(f"Successfully updated command status for guild {guild_id}, command {command_name}") + return result + except ImportError as e: + log.warning(f"Relative import failed: {e}, trying absolute import") + try: + # Fall back to absolute import + from cog_management_endpoints import update_command_status + log.info(f"Successfully imported update_command_status via absolute import") + + # Call the cog management endpoint + log.info(f"Calling update_command_status for guild {guild_id}, command {command_name}, enabled={enabled}") + result = await update_command_status(guild_id, command_name, enabled, _user, _admin) + log.info(f"Successfully updated command status for guild {guild_id}, command {command_name}") + return result + except ImportError as e2: + log.error(f"Both import attempts failed: {e2}") + log.warning("Falling back to direct implementation") + + # Fall back to direct implementation + # Check if settings_manager is available + if not settings_manager or not settings_manager.pg_pool: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Settings manager not available" + ) + + # Check if the command exists + try: + from discordbot import discord_bot_sync_api + bot = discord_bot_sync_api.bot_instance + if not bot: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Bot instance not available" + ) + + # Check if it's a prefix command + command = bot.get_command(command_name) + if not command: + # Check if it's an app command + app_commands = [cmd for cmd in bot.tree.get_commands() if cmd.name == command_name] + if not app_commands: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Command '{command_name}' not found" + ) + except ImportError: + # If we can't import the bot, we'll just assume the command exists + log.warning("Bot sync API not available, skipping command existence check") + + # Update the command enabled status + success = await settings_manager.set_command_enabled(guild_id, command_name, enabled) + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update command '{command_name}' status" + ) + + return {"message": f"Command '{command_name}' {'enabled' if enabled else 'disabled'} successfully"} + except HTTPException: + # Re-raise HTTP exceptions + raise except Exception as e: - log.error(f"Error redirecting to cog management endpoint for updating command status: {e}") + log.error(f"Error updating command status for guild {guild_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error updating command status: {str(e)}" diff --git a/settings_manager.py b/settings_manager.py index e61005e..8f491cf 100644 --- a/settings_manager.py +++ b/settings_manager.py @@ -2,6 +2,7 @@ import asyncpg import redis.asyncio as redis import os import logging +import asyncio from dotenv import load_dotenv from typing import Dict @@ -57,10 +58,25 @@ async def initialize_pools(): ) log.info(f"PostgreSQL pool connected to {POSTGRES_HOST}/{POSTGRES_DB}") - # Create Redis pool - redis_pool = redis.from_url(REDIS_URL, decode_responses=True) - await redis_pool.ping() # Test connection - log.info(f"Redis pool connected to {REDIS_HOST}:{REDIS_PORT}") + # Create Redis pool with connection_cls=None to avoid event loop issues + # This creates a connection pool that doesn't bind to a specific event loop + redis_pool = redis.from_url( + REDIS_URL, + decode_responses=True, + max_connections=20, # Limit max connections + socket_timeout=5.0, # 5 second timeout for operations + socket_connect_timeout=3.0, # 3 second timeout for connections + retry_on_timeout=True, # Retry on timeout + health_check_interval=30 # Check connection health every 30 seconds + ) + + # Test connection with a timeout + try: + await asyncio.wait_for(redis_pool.ping(), timeout=5.0) + log.info(f"Redis pool connected to {REDIS_HOST}:{REDIS_PORT}") + except asyncio.TimeoutError: + log.error(f"Redis connection timeout when connecting to {REDIS_HOST}:{REDIS_PORT}") + raise # Initialize database schema await initialize_database() # Ensure tables exist @@ -258,30 +274,56 @@ async def get_guild_prefix(guild_id: int, default_prefix: str) -> str: return default_prefix cache_key = _get_redis_key(guild_id, "prefix") + + # Try to get from cache with timeout and error handling try: - cached_prefix = await redis_pool.get(cache_key) + # Use a timeout to prevent hanging on Redis operations + cached_prefix = await asyncio.wait_for(redis_pool.get(cache_key), timeout=2.0) if cached_prefix is not None: log.debug(f"Cache hit for prefix (Guild: {guild_id})") return cached_prefix + except asyncio.TimeoutError: + log.warning(f"Redis timeout getting prefix for guild {guild_id}, falling back to database") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error for guild {guild_id}, falling back to database: {e}") + else: + log.exception(f"Redis error getting prefix for guild {guild_id}: {e}") except Exception as e: log.exception(f"Redis error getting prefix for guild {guild_id}: {e}") + # Cache miss or Redis error, get from database log.debug(f"Cache miss for prefix (Guild: {guild_id})") - async with pg_pool.acquire() as conn: - prefix = await conn.fetchval( - "SELECT setting_value FROM guild_settings WHERE guild_id = $1 AND setting_key = 'prefix'", - guild_id - ) - - final_prefix = prefix if prefix is not None else default_prefix - - # Cache the result (even if it's the default, to avoid future DB lookups) try: - await redis_pool.set(cache_key, final_prefix, ex=3600) # Cache for 1 hour - except Exception as e: - log.exception(f"Redis error setting prefix for guild {guild_id}: {e}") + async with pg_pool.acquire() as conn: + prefix = await conn.fetchval( + "SELECT setting_value FROM guild_settings WHERE guild_id = $1 AND setting_key = 'prefix'", + guild_id + ) - return final_prefix + final_prefix = prefix if prefix is not None else default_prefix + + # Try to cache the result with timeout and error handling + try: + # Use a timeout to prevent hanging on Redis operations + await asyncio.wait_for( + redis_pool.set(cache_key, final_prefix, ex=3600), # Cache for 1 hour + timeout=2.0 + ) + except asyncio.TimeoutError: + log.warning(f"Redis timeout setting prefix for guild {guild_id}") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error setting prefix for guild {guild_id}: {e}") + else: + log.exception(f"Redis error setting prefix for guild {guild_id}: {e}") + except Exception as e: + log.exception(f"Redis error setting prefix for guild {guild_id}: {e}") + + return final_prefix + except Exception as e: + log.exception(f"Database error getting prefix for guild {guild_id}: {e}") + return default_prefix # Fall back to default on database error async def set_guild_prefix(guild_id: int, prefix: str): """Sets the command prefix for a guild and updates the cache.""" @@ -326,33 +368,64 @@ async def get_setting(guild_id: int, key: str, default=None): return default cache_key = _get_redis_key(guild_id, "setting", key) + + # Try to get from cache with timeout and error handling try: - cached_value = await redis_pool.get(cache_key) + # Use a timeout to prevent hanging on Redis operations + cached_value = await asyncio.wait_for(redis_pool.get(cache_key), timeout=2.0) if cached_value is not None: # Note: Redis stores everything as strings. Consider type conversion if needed. log.debug(f"Cache hit for setting '{key}' (Guild: {guild_id})") + # Handle the None marker + if cached_value == "__NONE__": + return default return cached_value + except asyncio.TimeoutError: + log.warning(f"Redis timeout getting setting '{key}' for guild {guild_id}, falling back to database") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error for guild {guild_id}, falling back to database: {e}") + else: + log.exception(f"Redis error getting setting '{key}' for guild {guild_id}: {e}") except Exception as e: log.exception(f"Redis error getting setting '{key}' for guild {guild_id}: {e}") + # Cache miss or Redis error, get from database log.debug(f"Cache miss for setting '{key}' (Guild: {guild_id})") - async with pg_pool.acquire() as conn: - value = await conn.fetchval( - "SELECT setting_value FROM guild_settings WHERE guild_id = $1 AND setting_key = $2", - guild_id, key - ) - - final_value = value if value is not None else default - - # Cache the result (even if None or default, cache the absence or default value) - # Store None as a special marker, e.g., "None" string, or handle appropriately - value_to_cache = final_value if final_value is not None else "__NONE__" # Marker for None try: - await redis_pool.set(cache_key, value_to_cache, ex=3600) # Cache for 1 hour - except Exception as e: - log.exception(f"Redis error setting cache for setting '{key}' for guild {guild_id}: {e}") + async with pg_pool.acquire() as conn: + value = await conn.fetchval( + "SELECT setting_value FROM guild_settings WHERE guild_id = $1 AND setting_key = $2", + guild_id, key + ) - return final_value + final_value = value if value is not None else default + + # Cache the result (even if None or default, cache the absence or default value) + # Store None as a special marker, e.g., "None" string, or handle appropriately + value_to_cache = final_value if final_value is not None else "__NONE__" # Marker for None + + # Try to cache the result with timeout and error handling + try: + # Use a timeout to prevent hanging on Redis operations + await asyncio.wait_for( + redis_pool.set(cache_key, value_to_cache, ex=3600), # Cache for 1 hour + timeout=2.0 + ) + except asyncio.TimeoutError: + log.warning(f"Redis timeout setting cache for setting '{key}' for guild {guild_id}") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error setting cache for setting '{key}' for guild {guild_id}: {e}") + else: + log.exception(f"Redis error setting cache for setting '{key}' for guild {guild_id}: {e}") + except Exception as e: + log.exception(f"Redis error setting cache for setting '{key}' for guild {guild_id}: {e}") + + return final_value + except Exception as e: + log.exception(f"Database error getting setting '{key}' for guild {guild_id}: {e}") + return default # Fall back to default on database error async def set_setting(guild_id: int, key: str, value: str | None): @@ -411,14 +484,25 @@ async def is_cog_enabled(guild_id: int, cog_name: str, default_enabled: bool = T return default_enabled cache_key = _get_redis_key(guild_id, "cog_enabled", cog_name) + + # Try to get from cache with timeout and error handling try: - cached_value = await redis_pool.get(cache_key) + # Use a timeout to prevent hanging on Redis operations + cached_value = await asyncio.wait_for(redis_pool.get(cache_key), timeout=2.0) if cached_value is not None: log.debug(f"Cache hit for cog enabled status '{cog_name}' (Guild: {guild_id})") return cached_value == "True" # Redis stores strings + except asyncio.TimeoutError: + log.warning(f"Redis timeout getting cog enabled status for '{cog_name}' (Guild: {guild_id}), falling back to database") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error for guild {guild_id}, falling back to database: {e}") + else: + log.exception(f"Redis error getting cog enabled status for '{cog_name}' (Guild: {guild_id}): {e}") except Exception as e: log.exception(f"Redis error getting cog enabled status for '{cog_name}' (Guild: {guild_id}): {e}") + # Cache miss or Redis error, get from database log.debug(f"Cache miss for cog enabled status '{cog_name}' (Guild: {guild_id})") db_enabled_status = None try: @@ -427,21 +511,32 @@ async def is_cog_enabled(guild_id: int, cog_name: str, default_enabled: bool = T "SELECT enabled FROM enabled_cogs WHERE guild_id = $1 AND cog_name = $2", guild_id, cog_name ) + + final_status = db_enabled_status if db_enabled_status is not None else default_enabled + + # Try to cache the result with timeout and error handling + try: + # Use a timeout to prevent hanging on Redis operations + await asyncio.wait_for( + redis_pool.set(cache_key, str(final_status), ex=3600), # Cache for 1 hour + timeout=2.0 + ) + except asyncio.TimeoutError: + log.warning(f"Redis timeout setting cache for cog enabled status '{cog_name}' (Guild: {guild_id})") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error setting cache for cog enabled status '{cog_name}' (Guild: {guild_id}): {e}") + else: + log.exception(f"Redis error setting cache for cog enabled status '{cog_name}' (Guild: {guild_id}): {e}") + except Exception as e: + log.exception(f"Redis error setting cache for cog enabled status '{cog_name}' (Guild: {guild_id}): {e}") + + return final_status except Exception as e: log.exception(f"Database error getting cog enabled status for '{cog_name}' (Guild: {guild_id}): {e}") # Fallback to default on DB error after cache miss return default_enabled - final_status = db_enabled_status if db_enabled_status is not None else default_enabled - - # Cache the result (True or False) - try: - await redis_pool.set(cache_key, str(final_status), ex=3600) # Cache for 1 hour - except Exception as e: - log.exception(f"Redis error setting cache for cog enabled status '{cog_name}' (Guild: {guild_id}): {e}") - - return final_status - async def set_cog_enabled(guild_id: int, cog_name: str, enabled: bool): """Sets the enabled status for a cog in a guild and updates the cache.""" @@ -486,14 +581,25 @@ async def is_command_enabled(guild_id: int, command_name: str, default_enabled: return default_enabled cache_key = _get_redis_key(guild_id, "cmd_enabled", command_name) + + # Try to get from cache with timeout and error handling try: - cached_value = await redis_pool.get(cache_key) + # Use a timeout to prevent hanging on Redis operations + cached_value = await asyncio.wait_for(redis_pool.get(cache_key), timeout=2.0) if cached_value is not None: log.debug(f"Cache hit for command enabled status '{command_name}' (Guild: {guild_id})") return cached_value == "True" # Redis stores strings + except asyncio.TimeoutError: + log.warning(f"Redis timeout getting command enabled status for '{command_name}' (Guild: {guild_id}), falling back to database") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error for guild {guild_id}, falling back to database: {e}") + else: + log.exception(f"Redis error getting command enabled status for '{command_name}' (Guild: {guild_id}): {e}") except Exception as e: log.exception(f"Redis error getting command enabled status for '{command_name}' (Guild: {guild_id}): {e}") + # Cache miss or Redis error, get from database log.debug(f"Cache miss for command enabled status '{command_name}' (Guild: {guild_id})") db_enabled_status = None try: @@ -502,21 +608,32 @@ async def is_command_enabled(guild_id: int, command_name: str, default_enabled: "SELECT enabled FROM enabled_commands WHERE guild_id = $1 AND command_name = $2", guild_id, command_name ) + + final_status = db_enabled_status if db_enabled_status is not None else default_enabled + + # Try to cache the result with timeout and error handling + try: + # Use a timeout to prevent hanging on Redis operations + await asyncio.wait_for( + redis_pool.set(cache_key, str(final_status), ex=3600), # Cache for 1 hour + timeout=2.0 + ) + except asyncio.TimeoutError: + log.warning(f"Redis timeout setting cache for command enabled status '{command_name}' (Guild: {guild_id})") + except RuntimeError as e: + if "got Future" in str(e) and "attached to a different loop" in str(e): + log.warning(f"Redis event loop error setting cache for command enabled status '{command_name}' (Guild: {guild_id}): {e}") + else: + log.exception(f"Redis error setting cache for command enabled status '{command_name}' (Guild: {guild_id}): {e}") + except Exception as e: + log.exception(f"Redis error setting cache for command enabled status '{command_name}' (Guild: {guild_id}): {e}") + + return final_status except Exception as e: log.exception(f"Database error getting command enabled status for '{command_name}' (Guild: {guild_id}): {e}") # Fallback to default on DB error after cache miss return default_enabled - final_status = db_enabled_status if db_enabled_status is not None else default_enabled - - # Cache the result (True or False) - try: - await redis_pool.set(cache_key, str(final_status), ex=3600) # Cache for 1 hour - except Exception as e: - log.exception(f"Redis error setting cache for command enabled status '{command_name}' (Guild: {guild_id}): {e}") - - return final_status - async def set_command_enabled(guild_id: int, command_name: str, enabled: bool): """Sets the enabled status for a command in a guild and updates the cache."""