Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(redis): Implement caching #604

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ PROD_TOKEN=""
DEV_DATABASE_URL=""
DEV_TOKEN=""

REDIS_URL=redis://localhost:6379

#
# Optional
#

SENTRY_URL=""

PROD_COG_IGNORE_LIST=
Expand Down
2 changes: 2 additions & 0 deletions config/settings.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ USER_IDS:
TEMPVC_CATEGORY_ID: 123456789012345679
TEMPVC_CHANNEL_ID: 123456789012345679

REDIS_DEBUG_LOG: false

XP_BLACKLIST_CHANNEL:
- 123456789012345679
- 123456789012345679
Expand Down
14 changes: 13 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,16 @@ services:
ignore:
- .venv/
env_file:
- .env
- .env

redis:
image: redis:latest
container_name: redis
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data

volumes:
redis_data: {}
17 changes: 16 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ types-psutil = "^6.0.0.20240621"
types-pytz = "^2024.2.0.20240913"
types-pyyaml = "^6.0.12.20240808"
typing-extensions = "^4.12.2"
redis = "^5.1.1"

[tool.poetry.group.docs.dependencies]
mkdocs-material = "^9.5.30"
Expand Down
13 changes: 12 additions & 1 deletion tux/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

from tux.cog_loader import CogLoader
from tux.database.client import db
from tux.database.redis import redis_manager
from tux.utils.constants import CONST


class Tux(commands.Bot):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.setup_task = asyncio.create_task(self.setup())
self.is_shutting_down = False
self.redis = redis_manager

async def setup(self) -> None:
"""
Expand All @@ -26,9 +29,16 @@ async def setup(self) -> None:
logger.info(f"Prisma client connected: {db.is_connected()}")
logger.info(f"Prisma client registered: {db.is_registered()}")

# Connect to Redis
logger.info("Setting up Redis client...")
await self.redis.connect(CONST.REDIS_URL)

except Exception as e:
logger.critical(f"An error occurred while connecting to the database: {e}")
return
# You might want to exit the program here if the database connection fails
import sys

sys.exit(1)

# Load Jishaku for debugging
await self.load_extension("jishaku")
Expand Down Expand Up @@ -82,6 +92,7 @@ async def shutdown(self) -> None:
try:
logger.info("Closing database connections.")
await db.disconnect()
await self.redis.interface.close()

except Exception as e:
logger.critical(f"Error during database disconnection: {e}")
Expand Down
37 changes: 22 additions & 15 deletions tux/cogs/services/levels.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(self, bot: Tux) -> None:
self.levels_exponent = self.settings.get("LEVELS_EXPONENT")
self.xp_roles = {role["level"]: role["role_id"] for role in self.settings["XP_ROLES"]}
self.xp_multipliers = {role["role_id"]: role["multiplier"] for role in self.settings["XP_MULTIPLIERS"]}
self.redis = bot.redis.interface

@commands.Cog.listener("on_message")
async def xp_listener(self, message: discord.Message) -> None:
Expand Down Expand Up @@ -63,8 +64,7 @@ async def process_xp_gain(self, member: discord.Member, guild: discord.Guild) ->
if await self.levels_controller.is_blacklisted(member.id, guild.id):
return

last_message_time = await self.levels_controller.get_last_message_time(member.id, guild.id)
if last_message_time and self.is_on_cooldown(last_message_time):
if await self.is_on_cooldown(member.id, guild.id):
return

current_xp, current_level = await self.levels_controller.get_xp_and_level(member.id, guild.id)
Expand All @@ -73,36 +73,43 @@ async def process_xp_gain(self, member: discord.Member, guild: discord.Guild) ->
new_xp = current_xp + xp_increment
new_level = self.calculate_level(new_xp)

await self.levels_controller.update_xp_and_level(
member.id,
guild.id,
new_xp,
new_level,
datetime.datetime.fromtimestamp(time.time(), tz=datetime.UTC),
)
await self.update_xp_and_level(member.id, guild.id, new_xp, new_level)

if new_level > current_level:
logger.debug(f"User {member.name} leveled up from {current_level} to {new_level} in guild {guild.name}")
await self.handle_level_up(member, guild, new_level)

def is_on_cooldown(self, last_message_time: datetime.datetime) -> bool:
async def is_on_cooldown(self, user_id: int, guild_id: int) -> bool:
"""
Checks if the member is on cooldown.

Parameters
----------
last_message_time : datetime.datetime
The time of the last message.
user_id : int
The ID of the member.
guild_id : int
The ID of the guild.

Returns
-------
bool
True if the member is on cooldown, False otherwise.
"""
return (datetime.datetime.fromtimestamp(time.time(), tz=datetime.UTC) - last_message_time) < datetime.timedelta(
seconds=self.xp_cooldown,
cache_key = f"xp_cooldown:{user_id}:{guild_id}"
return await self.redis.exists(cache_key)

async def update_xp_and_level(self, user_id: int, guild_id: int, new_xp: float, new_level: int) -> None:
await self.levels_controller.update_xp_and_level(
user_id,
guild_id,
new_xp,
new_level,
datetime.datetime.fromtimestamp(time.time(), tz=datetime.UTC),
)

cooldown_key = f"xp_cooldown:{user_id}:{guild_id}"
await self.redis.set(cooldown_key, "1", ex=self.xp_cooldown)

async def handle_level_up(self, member: discord.Member, guild: discord.Guild, new_level: int) -> None:
"""
Handles the level up process for a member.
Expand Down Expand Up @@ -141,7 +148,7 @@ async def update_roles(self, member: discord.Member, guild: discord.Guild, new_l
roles_to_remove = [r for r in member.roles if r.id in self.xp_roles.values() and r != highest_role]
await member.remove_roles(*roles_to_remove)
logger.debug(
f"Assigned role {highest_role.name if highest_role else "None"} to member {member} and removed roles {", ".join(r.name for r in roles_to_remove)}",
f"Assigned role {highest_role.name if highest_role else 'None'} to member {member} and removed roles {', '.join(r.name for r in roles_to_remove)}",
)

@staticmethod
Expand Down
27 changes: 27 additions & 0 deletions tux/database/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import redis.asyncio as redis
from loguru import logger


class RedisManager:
def __init__(self):
self.interface: redis.Redis = redis.Redis()

async def connect(self, url: str) -> None:
"""
Connect to the Redis server.

Parameters
----------
url : str
The URL of the Redis server to connect to.
"""
try:
self.redis = redis.from_url(url, decode_responses=True) # type: ignore
await self.redis.ping() # type: ignore
logger.info("Successfully connected to Redis")

except redis.ConnectionError as e:
logger.warning(f"Failed to connect to Redis: {e}")


redis_manager = RedisManager()
4 changes: 4 additions & 0 deletions tux/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class Constants:
DEFAULT_DEV_PREFIX: Final[str] = config["DEFAULT_PREFIX"]["DEV"]
DEV_COG_IGNORE_LIST: Final[set[str]] = set(os.getenv("DEV_COG_IGNORE_LIST", "").split(","))

# Redis constants
REDIS_URL: Final[str] = os.getenv("REDIS_URL", "redis://localhost:6379")
REDIS_DEBUG_LOG: Final[bool] = config["REDIS_DEBUG_LOG"]

# Debug env constants
DEBUG: Final[bool] = bool(os.getenv("DEBUG", "True"))

Expand Down