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: convert redis to postgresql database #32

Merged
merged 2 commits into from
Dec 1, 2024
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Railway CLI
run: npm install -g @railway/cli

- name: Deploy to Railway
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: railway up --service laughable-reading
run: railway up --service laughable-reading --detach
61 changes: 61 additions & 0 deletions kusogaki_bot/data/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import logging
import os
from functools import lru_cache

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.pool import QueuePool

Base = declarative_base()


class Database:
"""Singleton class to manage PostgreSQL database connection."""

_instance = None

@classmethod
@lru_cache(maxsize=1)
def get_instance(cls):
"""Get database connection instance using singleton pattern."""
if cls._instance is None:
database_url = os.getenv('DATABASE_URL')
if not database_url:
raise ValueError('DATABASE_URL environment variable is not set')

try:
if database_url.startswith('postgres://'):
database_url = database_url.replace(
'postgres://', 'postgresql://', 1
)

engine = create_engine(
database_url,
poolclass=QueuePool,
pool_size=5,
max_overflow=10,
pool_timeout=30,
pool_recycle=1800,
)

cls._instance = sessionmaker(bind=engine)
Base.metadata.create_all(engine)
logging.info('Successfully connected to PostgreSQL database')
except Exception as e:
logging.error(f'Failed to connect to PostgreSQL: {str(e)}')
raise

return cls._instance()

@classmethod
def close(cls):
"""Close all database connections."""
if cls._instance is not None:
try:
engine = cls._instance.kw['bind']
engine.dispose()
cls._instance = None
cls.get_instance.cache_clear()
logging.info('Database connections closed')
except Exception as e:
logging.error(f'Error closing database connections: {str(e)}')
85 changes: 36 additions & 49 deletions kusogaki_bot/data/food_counter_repository.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,57 @@
import json
import logging
from datetime import datetime

import redis
from sqlalchemy.exc import SQLAlchemyError

from kusogaki_bot.data.redis_db import Database
from kusogaki_bot.models.food_counter import FoodCounter
from kusogaki_bot.data.db import Database
from kusogaki_bot.data.models import FoodCounter


class FoodCounterRepository:
"""Repository class for food counter persistence using Redis."""
"""Repository class for food counter persistence using PostgreSQL."""

def __init__(self, key_prefix='food_counter:'):
"""Initialize Redis connection using Database singleton."""
self.redis_client = Database.get_instance()
self.key_prefix = key_prefix
def __init__(self):
"""Initialize database connection using Database singleton."""
self.db = Database.get_instance()

def get_counter(self, user_id: str) -> FoodCounter:
"""Get a user's food counter from Redis."""
"""Get a user's food counter from the database."""
try:
key = f'{self.key_prefix}{user_id}'
data = self.redis_client.get(key)

if data:
try:
counter_data = json.loads(data)
return FoodCounter(
user_id=user_id,
count=counter_data['count'],
last_updated=datetime.fromisoformat(
counter_data['last_updated']
),
)
except (json.JSONDecodeError, KeyError) as e:
logging.error(
f'Error decoding food counter for user {user_id}: {str(e)}'
)
return FoodCounter(user_id=user_id)
return FoodCounter(user_id=user_id)

except redis.RedisError as e:
logging.error(f'Error loading food counter from Redis: {str(e)}')
counter = self.db.query(FoodCounter).filter_by(user_id=user_id).first()
if not counter:
counter = FoodCounter(user_id=user_id)
return counter
except SQLAlchemyError as e:
logging.error(f'Error loading food counter from database: {str(e)}')
return FoodCounter(user_id=user_id)

def save_counter(self, counter: FoodCounter) -> None:
"""Save a food counter to Redis."""
"""Save a food counter to the database."""
try:
key = f'{self.key_prefix}{counter.user_id}'
data = {
'count': counter.count,
'last_updated': counter.last_updated.isoformat(),
}

if counter.count > 0:
self.redis_client.set(key, json.dumps(data))
existing = (
self.db.query(FoodCounter)
.filter_by(user_id=counter.user_id)
.first()
)
if existing:
existing.count = counter.count
existing.last_updated = datetime.now()
else:
self.db.add(counter)
else:
self.redis_client.delete(key)
self.db.query(FoodCounter).filter_by(user_id=counter.user_id).delete()

except redis.RedisError as e:
logging.error(f'Error saving food counter to Redis: {str(e)}')
self.db.commit()
except SQLAlchemyError as e:
logging.error(f'Error saving food counter to database: {str(e)}')
self.db.rollback()

def clear_all(self) -> None:
"""Clear all food counters from Redis (useful for testing)."""
"""Clear all food counters from the database (useful for testing)."""
try:
keys = self.redis_client.keys(f'{self.key_prefix}*')
if keys:
self.redis_client.delete(*keys)
except redis.RedisError as e:
logging.error(f'Error clearing food counters from Redis: {str(e)}')
self.db.query(FoodCounter).delete()
self.db.commit()
except SQLAlchemyError as e:
logging.error(f'Error clearing food counters from database: {str(e)}')
self.db.rollback()
26 changes: 26 additions & 0 deletions kusogaki_bot/data/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from sqlalchemy import JSON, Column, DateTime, Integer, String
from sqlalchemy.sql import func

from kusogaki_bot.data.db import Base


class FoodCounter(Base):
__tablename__ = 'food_counters'

user_id = Column(String, primary_key=True)
count = Column(Integer, default=0)
last_updated = Column(DateTime, default=func.now(), onupdate=func.now())


class Reminder(Base):
__tablename__ = 'reminders'

user_id = Column(String, primary_key=True)
data = Column(JSON, default={})


class ScheduledThread(Base):
__tablename__ = 'scheduled_threads'

id = Column(Integer, primary_key=True)
data = Column(JSON, default={})
51 changes: 0 additions & 51 deletions kusogaki_bot/data/redis_db.py

This file was deleted.

92 changes: 41 additions & 51 deletions kusogaki_bot/data/reminder_repository.py
Original file line number Diff line number Diff line change
@@ -1,73 +1,63 @@
import json
import logging
from typing import Dict

import redis
from sqlalchemy.exc import SQLAlchemyError

from kusogaki_bot.data.redis_db import Database
from kusogaki_bot.data.db import Database
from kusogaki_bot.data.models import Reminder


class ReminderRepository:
"""Repository class for reminder persistence using Redis."""
"""Repository class for reminder persistence using PostgreSQL."""

def __init__(self, key_prefix='reminder:'):
"""Initialize Redis connection using Database singleton."""
self.redis_client = Database.get_instance()
self.key_prefix = key_prefix
def __init__(self):
"""Initialize database connection using Database singleton."""
self.db = Database.get_instance()

def load(self) -> dict:
"""Load all reminders from Redis."""
def load(self) -> Dict:
"""Load all reminders from database."""
try:
all_keys = self.redis_client.keys(f'{self.key_prefix}*')
if not all_keys:
return {}

all_values = self.redis_client.mget(all_keys)

reminders = {}
for key, value in zip(all_keys, all_values):
if value is None:
continue
user_id = key.replace(self.key_prefix, '')
try:
reminders[user_id] = json.loads(value)
except json.JSONDecodeError as e:
logging.error(
f'Error decoding reminders for user {user_id}: {str(e)}'
)
continue

results = self.db.query(Reminder).all()
for reminder in results:
reminders[reminder.user_id] = reminder.data
return reminders
except redis.RedisError as e:
logging.error(f'Error loading reminders from Redis: {str(e)}')
except SQLAlchemyError as e:
logging.error(f'Error loading reminders from database: {str(e)}')
return {}

def save(self, data: dict) -> None:
"""Save reminders to Redis."""
def save(self, data: Dict) -> None:
"""Save reminders to database."""
try:
existing_keys = self.redis_client.keys(f'{self.key_prefix}*')

current_user_keys = {
f'{self.key_prefix}{user_id}' for user_id in data.keys()
}
keys_to_delete = set(existing_keys) - current_user_keys
if keys_to_delete:
self.redis_client.delete(*keys_to_delete)
existing_user_ids = {r.user_id for r in self.db.query(Reminder).all()}
users_to_delete = existing_user_ids - set(data.keys())
if users_to_delete:
self.db.query(Reminder).filter(
Reminder.user_id.in_(users_to_delete)
).delete(synchronize_session=False)

for user_id, reminders in data.items():
key = f'{self.key_prefix}{user_id}'
if reminders:
self.redis_client.set(key, json.dumps(reminders))
existing = (
self.db.query(Reminder).filter_by(user_id=user_id).first()
)
if existing:
existing.data = reminders
else:
self.db.add(Reminder(user_id=user_id, data=reminders))
else:
self.redis_client.delete(key)
self.db.query(Reminder).filter_by(user_id=user_id).delete()

except redis.RedisError as e:
logging.error(f'Error saving reminders to Redis: {str(e)}')
self.db.commit()
except SQLAlchemyError as e:
logging.error(f'Error saving reminders to database: {str(e)}')
self.db.rollback()

def clear_all(self) -> None:
"""Clear all reminders from Redis (useful for testing)."""
"""Clear all reminders from database (useful for testing)."""
try:
keys = self.redis_client.keys(f'{self.key_prefix}*')
if keys:
self.redis_client.delete(*keys)
except redis.RedisError as e:
logging.error(f'Error clearing reminders from Redis: {str(e)}')
self.db.query(Reminder).delete()
self.db.commit()
except SQLAlchemyError as e:
logging.error(f'Error clearing reminders from database: {str(e)}')
self.db.rollback()
Loading