"""In-memory translation session store with 24h TTL.""" import uuid from datetime import datetime, timezone, timedelta from typing import Any from backend.models import TranslationSession class SessionConflictError(Exception): """Raised when a long-running phase tries to update a changed session.""" class SessionStore: """Process-local in-memory store for user-owned translation sessions. Each session is keyed by a UUID and has a 24-hour TTL. Expired sessions are lazily cleaned on access. """ def __init__(self, ttl_hours: int = 24): self._store: dict[str, dict[str, Any]] = {} self.ttl_hours = ttl_hours def create(self, session_data: TranslationSession, owner_id: str) -> str: session_id = uuid.uuid4().hex[:16] self._store[session_id] = { "data": session_data.model_dump(), "owner_id": owner_id, "version": 0, "created_at": datetime.now(timezone.utc), "updated_at": datetime.now(timezone.utc), } return session_id def get(self, session_id: str, owner_id: str) -> TranslationSession | None: result = self.get_with_version(session_id, owner_id) return result[0] if result else None def get_with_version( self, session_id: str, owner_id: str ) -> tuple[TranslationSession, int] | None: entry = self._store.get(session_id) if entry is None or entry["owner_id"] != owner_id: return None # Check TTL now = datetime.now(timezone.utc) age = now - entry["updated_at"] if age > timedelta(hours=self.ttl_hours): del self._store[session_id] return None # Touch (refresh TTL) entry["updated_at"] = now return TranslationSession(**entry["data"]), entry["version"] def update( self, session_id: str, data: dict[str, Any], owner_id: str, expected_version: int | None = None, ) -> TranslationSession | None: entry = self._store.get(session_id) if entry is None or entry["owner_id"] != owner_id: return None now = datetime.now(timezone.utc) if now - entry["updated_at"] > timedelta(hours=self.ttl_hours): del self._store[session_id] return None if expected_version is not None and entry["version"] != expected_version: raise SessionConflictError entry["data"].update(data) entry["version"] += 1 entry["updated_at"] = now return TranslationSession(**entry["data"]) def delete(self, session_id: str, owner_id: str) -> bool: entry = self._store.get(session_id) if entry is not None and entry["owner_id"] == owner_id: del self._store[session_id] return True return False def set_progress( self, session_id: str, progress: dict[str, Any] | None, owner_id: str ) -> bool: """Update transient progress without changing the semantic session version.""" entry = self._store.get(session_id) if entry is None or entry["owner_id"] != owner_id: return False now = datetime.now(timezone.utc) if now - entry["updated_at"] > timedelta(hours=self.ttl_hours): del self._store[session_id] return False entry["data"]["progress"] = progress entry["updated_at"] = now return True def cleanup_expired(self): """Remove all expired sessions.""" now = datetime.now(timezone.utc) expired_keys = [ k for k, v in self._store.items() if (now - v["updated_at"]) > timedelta(hours=self.ttl_hours) ] for k in expired_keys: del self._store[k] # Singleton instance session_store = SessionStore(ttl_hours=24)