multilingual_rag / cache /conversation.py
ajoy0071998's picture
database update
b146c64
Raw
History Blame Contribute Delete
23.6 kB
from logger.logger import get_logger
logger = get_logger(__name__)
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
import asyncio
import uuid
import json
from services.conversation_service import conversation_db
from cache.redis_client import redis_client
class ConversationCache:
def __init__(self, ttl_hours: int = 2, batch_size: int = 50):
self.ttl_seconds = ttl_hours * 3600
self.delete_after_seconds = 3600
self.batch_size = batch_size
self._lock = asyncio.Lock()
self._cleanup_task = None
self._shutdown_event = asyncio.Event()
self._save_queue: asyncio.Queue = asyncio.Queue()
self._worker_task = None
self._is_running = True
self._redis = redis_client
self._redis_initialized = False
def _ensure_redis(self):
if not self._redis_initialized:
self._redis.connect()
self._redis_initialized = True
def _get_conv_key(self, conv_id: str) -> str:
return f"conv:{conv_id}"
def _get_user_key(self, user_id: str) -> str:
return f"user:{user_id}:convs"
def _get_msgs_key(self, conv_id: str) -> str:
return f"conv:{conv_id}:msgs"
def _get_saved_key(self, conv_id: str) -> str:
return f"conv:{conv_id}:saved"
def _get_status_key(self, conv_id: str) -> str:
return f"conv:{conv_id}:status"
def _get_msg_id(self, message: Dict[str, Any]) -> str:
ts = message['timestamp'].isoformat() if isinstance(message['timestamp'], datetime) else message['timestamp']
return f"{message['role']}:{message['content'][:50]}:{ts}"
def _format_response(self, conv: Dict[str, Any], include_messages: bool, max_messages: Optional[int]) -> Dict[str, Any]:
conv_copy = conv.copy()
for field in ["expires_at", "created_at", "updated_at"]:
if field in conv_copy and isinstance(conv_copy[field], datetime):
conv_copy[field] = conv_copy[field].isoformat()
if not include_messages:
conv_copy.pop("messages", None)
elif max_messages and conv_copy.get("messages"):
conv_copy["messages"] = conv_copy["messages"][-max_messages:]
for msg in conv_copy["messages"]:
if isinstance(msg["timestamp"], datetime):
msg["timestamp"] = msg["timestamp"].isoformat()
return conv_copy
async def _load_from_redis(self, conv_id: str) -> Optional[Dict[str, Any]]:
try:
self._ensure_redis()
conv_data = self._redis.hgetall(self._get_conv_key(conv_id))
if not conv_data:
return None
raw_msgs = self._redis.zrange(self._get_msgs_key(conv_id), 0, -1)
messages = []
for raw in raw_msgs:
msg = json.loads(raw)
msg["timestamp"] = datetime.fromisoformat(msg["timestamp"])
msg["metadata"] = json.loads(msg["metadata"])
messages.append(msg)
return {
"conversation_id": conv_data["conversation_id"],
"user_id": conv_data["user_id"],
"metadata": json.loads(conv_data["metadata"]),
"created_at": datetime.fromisoformat(conv_data["created_at"]),
"updated_at": datetime.fromisoformat(conv_data["updated_at"]),
"expires_at": datetime.fromisoformat(conv_data["expires_at"]),
"messages": messages
}
except Exception as e:
logger.error(f"Error loading from Redis: {e}")
return None
async def _save_to_redis(self, conv_data: Dict[str, Any]) -> bool:
try:
self._ensure_redis()
now = datetime.now()
pipe = self._redis.pipeline()
pipe.hset(self._get_conv_key(conv_data["conversation_id"]), mapping={
"conversation_id": conv_data["conversation_id"],
"user_id": conv_data["user_id"],
"metadata": json.dumps(conv_data["metadata"]),
"created_at": conv_data["created_at"].isoformat(),
"updated_at": conv_data["updated_at"].isoformat(),
"expires_at": (now + timedelta(seconds=self.ttl_seconds)).isoformat()
})
pipe.expire(self._get_conv_key(conv_data["conversation_id"]), self.ttl_seconds)
pipe.lpush(self._get_user_key(conv_data["user_id"]), conv_data["conversation_id"])
pipe.expire(self._get_user_key(conv_data["user_id"]), self.ttl_seconds)
for msg in conv_data.get("messages", []):
msg_data = {
"role": msg["role"],
"content": msg["content"],
"metadata": json.dumps(msg.get("metadata", {})),
"timestamp": msg["timestamp"].isoformat() if isinstance(msg["timestamp"], datetime) else msg["timestamp"]
}
ts = msg["timestamp"].timestamp() if isinstance(msg["timestamp"], datetime) else datetime.fromisoformat(msg["timestamp"]).timestamp()
pipe.zadd(self._get_msgs_key(conv_data["conversation_id"]), {json.dumps(msg_data): ts})
pipe.expire(self._get_msgs_key(conv_data["conversation_id"]), self.ttl_seconds)
pipe.set(self._get_status_key(conv_data["conversation_id"]), "restored", ex=self.ttl_seconds)
pipe.execute()
return True
except Exception as e:
logger.error(f"Error saving to Redis: {e}")
return False
async def _db_worker(self):
while self._is_running or not self._save_queue.empty():
try:
# Wait until an item is available
conv_id, data = await self._save_queue.get()
try:
success = False
if (
isinstance(data, dict)
and data.get("action") == "create_conversation"
):
conv_data = await self._load_from_redis(conv_id)
if conv_data:
success = await conversation_db.save_conversation(conv_data)
elif (
isinstance(data, dict)
and data.get("action") == "update_metadata"
):
conv_data = await self._load_from_redis(conv_id)
if conv_data:
success = await conversation_db.save_conversation(conv_data)
else:
success = await conversation_db.save_message(conv_id, data)
if success:
logger.debug(f"Successfully saved item for conversation {conv_id}")
else:
logger.warning(f"Failed to save item for conversation {conv_id}")
except Exception as e:
logger.exception(f"Error processing conversation {conv_id}: {e}")
finally:
# Mark queue item as completed ONLY after DB work finishes
self._save_queue.task_done()
except asyncio.CancelledError:
logger.info("DB worker cancelled")
break
except Exception as e:
logger.exception(f"Unexpected error in DB worker: {e}")
await asyncio.sleep(1)
async def create_conversation(self, user_id: str, conversation_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> str:
async with self._lock:
if conversation_id is None:
conversation_id = str(uuid.uuid4())
now = datetime.now()
self._ensure_redis()
# Save to Redis immediately
pipe = self._redis.pipeline()
pipe.hset(self._get_conv_key(conversation_id), mapping={
"conversation_id": conversation_id,
"user_id": user_id,
"metadata": json.dumps(metadata or {}),
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
"expires_at": (now + timedelta(seconds=self.ttl_seconds)).isoformat()
})
pipe.expire(self._get_conv_key(conversation_id), self.ttl_seconds)
pipe.lpush(self._get_user_key(user_id), conversation_id)
pipe.expire(self._get_user_key(user_id), self.ttl_seconds)
pipe.set(self._get_status_key(conversation_id), "pending", ex=self.ttl_seconds)
pipe.execute()
# Queue for background DB save
await self._save_queue.put((conversation_id, {"action": "create_conversation"}))
logger.info(f"Created conversation {conversation_id} for user {user_id} in Redis")
return conversation_id
async def add_message(self, conversation_id: str, role: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
async with self._lock:
# Check Redis first
self._ensure_redis()
if not self._redis.exists(self._get_conv_key(conversation_id)):
# If not in Redis, try to load from DB and restore to Redis
conv_data = await conversation_db.load_conversation(conversation_id)
if not conv_data:
raise ValueError(f"Conversation {conversation_id} not found")
await self._save_to_redis(conv_data)
now = datetime.now()
message = {"role": role, "content": content, "timestamp": now, "metadata": metadata or {}}
# Save to Redis immediately
pipe = self._redis.pipeline()
pipe.expire(self._get_conv_key(conversation_id), self.ttl_seconds)
msg_data = {"role": role, "content": content, "metadata": json.dumps(metadata or {}), "timestamp": now.isoformat()}
pipe.zadd(self._get_msgs_key(conversation_id), {json.dumps(msg_data): now.timestamp()})
pipe.expire(self._get_msgs_key(conversation_id), self.ttl_seconds)
pipe.hset(self._get_conv_key(conversation_id), "updated_at", now.isoformat())
pipe.hset(self._get_conv_key(conversation_id), "expires_at", (now + timedelta(seconds=self.ttl_seconds)).isoformat())
pipe.execute()
# Queue for background DB save
await self._save_queue.put((conversation_id, message))
logger.debug(f"Added {role} message to conversation {conversation_id} in Redis")
return message
async def get_conversation(self, conversation_id: str, include_messages: bool = True, max_messages: Optional[int] = None) -> Optional[Dict[str, Any]]:
async with self._lock:
# Always read from Redis first
conv_data = await self._load_from_redis(conversation_id)
if conv_data:
logger.debug(f"Conversation {conversation_id} found in Redis")
return self._format_response(conv_data, include_messages, max_messages)
# If not in Redis, check Database
logger.debug(f"Conversation {conversation_id} not in Redis, checking DB")
conv_data = await conversation_db.load_conversation(conversation_id)
if conv_data:
# Restore to Redis for future requests
await self._save_to_redis(conv_data)
return self._format_response(conv_data, include_messages, max_messages)
return None
async def get_user_conversations(self, user_id: str, limit: int = 10, include_messages: bool = False) -> List[Dict[str, Any]]:
async with self._lock:
# Always read from Redis first
self._ensure_redis()
conv_ids = self._redis.lrange(self._get_user_key(user_id), 0, limit - 1)
if conv_ids:
logger.debug(f"User {user_id} conversations found in Redis")
convs = []
for conv_id in conv_ids:
conv = await self.get_conversation(conv_id, include_messages)
if conv:
convs.append(conv)
return convs
# If not in Redis, check Database
logger.debug(f"User {user_id} conversations not in Redis, checking DB")
convs = await conversation_db.load_user_conversations(user_id, limit)
# Restore to Redis
for conv in convs:
await self._save_to_redis(conv)
return convs
async def update_conversation_metadata(self, conversation_id: str, metadata: Dict[str, Any]) -> bool:
async with self._lock:
# Check Redis first
conv_data = await self.get_conversation(conversation_id, include_messages=True)
if not conv_data:
return False
conv_data["metadata"].update(metadata)
conv_data["updated_at"] = datetime.now()
# Update in Redis immediately
self._ensure_redis()
pipe = self._redis.pipeline()
pipe.hset(self._get_conv_key(conversation_id), "metadata", json.dumps(conv_data["metadata"]))
pipe.hset(self._get_conv_key(conversation_id), "updated_at", datetime.now().isoformat())
pipe.execute()
# Queue for background DB save
await self._save_queue.put((conversation_id, {"action": "update_metadata", "metadata": metadata}))
logger.debug(f"Updated metadata for conversation {conversation_id}")
return True
async def delete_conversation(self, conversation_id: str) -> bool:
async with self._lock:
# Check Redis first
conv_data = await self.get_conversation(conversation_id, include_messages=True)
if not conv_data:
return False
# Flush pending saves to DB
await self.flush_all_to_db()
# Save to DB if not already saved
status = self._redis.get(self._get_status_key(conversation_id))
if status != "saved":
await conversation_db.save_conversation(conv_data)
# Delete from Redis
self._ensure_redis()
pipe = self._redis.pipeline()
pipe.delete(self._get_conv_key(conversation_id))
pipe.delete(self._get_msgs_key(conversation_id))
pipe.delete(self._get_saved_key(conversation_id))
pipe.delete(self._get_status_key(conversation_id))
pipe.lrem(self._get_user_key(conv_data["user_id"]), 0, conversation_id)
pipe.execute()
logger.info(f"Deleted conversation {conversation_id} from Redis")
return True
async def delete_user_conversations(self, user_id: str) -> int:
async with self._lock:
self._ensure_redis()
conv_ids = self._redis.lrange(self._get_user_key(user_id), 0, -1)
count = 0
for conv_id in conv_ids:
conv_data = await self.get_conversation(conv_id, include_messages=True)
if conv_data:
status = self._redis.get(self._get_status_key(conv_id))
if status != "saved":
await conversation_db.save_conversation(conv_data)
pipe = self._redis.pipeline()
pipe.delete(self._get_conv_key(conv_id))
pipe.delete(self._get_msgs_key(conv_id))
pipe.delete(self._get_saved_key(conv_id))
pipe.delete(self._get_status_key(conv_id))
pipe.execute()
count += 1
self._redis.delete(self._get_user_key(user_id))
await self.flush_all_to_db()
logger.info(f"Deleted {count} conversations for user {user_id}")
return count
async def get_conversation_history(self, conversation_id: str, max_messages: int = 20, format_type: str = "list") -> List:
conv = await self.get_conversation(conversation_id, include_messages=True, max_messages=max_messages)
if not conv:
return []
messages_list = conv.get("messages", [])
if format_type == "string":
return [f"{msg['role']}: {msg['content']}" for msg in messages_list]
return [{"role": msg["role"], "content": msg["content"]} for msg in messages_list]
async def clear_expired(self) -> int:
self._ensure_redis()
pattern = "conv:*"
cursor = 0
deleted = 0
while True:
cursor, keys = self._redis.get_client().scan(cursor, match=pattern, count=100)
for key in keys:
if ":" in key:
conv_id = key.split(":")[1]
created_at_str = self._redis.hget(key, "created_at")
if created_at_str:
created_at = datetime.fromisoformat(created_at_str)
age = (datetime.now() - created_at).total_seconds()
if age > self.delete_after_seconds:
status = self._redis.get(self._get_status_key(conv_id))
if status == "saved":
user_id = self._redis.hget(key, "user_id")
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.delete(self._get_msgs_key(conv_id))
pipe.delete(self._get_saved_key(conv_id))
pipe.delete(self._get_status_key(conv_id))
if user_id:
pipe.lrem(self._get_user_key(user_id), 0, conv_id)
pipe.execute()
deleted += 1
if cursor == 0:
break
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired conversations from Redis")
return deleted
async def get_stats(self) -> Dict[str, Any]:
self._ensure_redis()
pattern = "conv:*"
cursor = 0
conv_count = 0
msg_count = 0
while True:
cursor, keys = self._redis.get_client().scan(cursor, match=pattern, count=100)
for key in keys:
if ":" in key:
conv_id = key.split(":")[1]
if conv_id:
conv_count += 1
msg_count += self._redis.zcard(self._get_msgs_key(conv_id))
if cursor == 0:
break
return {
"total_conversations": conv_count,
"total_messages": msg_count,
"ttl_hours": self.ttl_seconds // 3600,
"delete_after_hours": self.delete_after_seconds // 3600,
"queue_size": self._save_queue.qsize(),
"redis_connected": self._redis_initialized and self._redis.ping()
}
async def start_background_tasks(self):
await self.start_cleanup_task()
if self._worker_task is None or self._worker_task.done():
self._is_running = True
self._worker_task = asyncio.create_task(self._db_worker())
logger.info("Started DB worker task")
async def stop_background_tasks(self):
await self.stop_cleanup_task()
self._is_running = False
if self._worker_task and not self._worker_task.done():
self._worker_task.cancel()
try:
await self._worker_task
except asyncio.CancelledError:
pass
logger.info("Stopped DB worker task")
async def start_cleanup_task(self):
if self._cleanup_task is None or self._cleanup_task.done():
self._shutdown_event.clear()
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info("Started background cleanup task")
async def stop_cleanup_task(self):
if self._cleanup_task and not self._cleanup_task.done():
self._shutdown_event.set()
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("Stopped background cleanup task")
async def _cleanup_loop(self):
try:
while not self._shutdown_event.is_set():
try:
await asyncio.wait_for(self._shutdown_event.wait(), timeout=3600)
break
except asyncio.TimeoutError:
removed = await self.clear_expired()
if removed > 0:
logger.info(f"Cleanup job removed {removed} expired conversations")
except asyncio.CancelledError:
logger.info("Cleanup task received cancellation signal")
raise
except Exception as e:
logger.error(f"Error in cleanup loop: {e}", exc_info=True)
async def flush_all_to_db(self) -> int:
remaining = self._save_queue.qsize()
if remaining > 0:
logger.info(f"Flushing {remaining} items to database...")
await self._save_queue.join()
logger.info(f"Flushed all {remaining} items to database")
return remaining
async def save_all_conversations_to_db(self) -> int:
await self.flush_all_to_db()
self._ensure_redis()
pattern = "conv:*"
cursor = 0
saved = 0
while True:
cursor, keys = self._redis.get_client().scan(cursor, match=pattern, count=100)
for key in keys:
if ":" in key:
conv_id = key.split(":")[1]
if conv_id:
conv_data = await self._load_from_redis(conv_id)
if conv_data:
if await conversation_db.save_conversation(conv_data):
saved += 1
if cursor == 0:
break
logger.info(f"Saved {saved} conversations to DB during shutdown")
return saved
async def close(self):
self._redis.close()
cache = ConversationCache(ttl_hours=2)
async def initialize_cache():
await cache.start_background_tasks()
cache._ensure_redis()