Add persistent multi-user sessions and Docker deployment
This commit is contained in:
parent
51a6e845d1
commit
104577c826
24 changed files with 2094 additions and 468 deletions
|
|
@ -1,37 +1,25 @@
|
|||
"""In-memory translation session store with 24h TTL."""
|
||||
"""Persistent translation session store with transient in-memory progress."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from backend.models import TranslationSession
|
||||
|
||||
|
||||
class SessionConflictError(Exception):
|
||||
"""Raised when a long-running phase tries to update a changed session."""
|
||||
from backend.database import (
|
||||
Database,
|
||||
SessionArchivedError,
|
||||
SessionConflictError,
|
||||
database,
|
||||
)
|
||||
from backend.models import PhaseProgress, TranslationSession
|
||||
|
||||
|
||||
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 __init__(self, db: Database | None = None):
|
||||
self.db = db or database
|
||||
self._progress: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
self._progress_lock = threading.RLock()
|
||||
|
||||
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
|
||||
return self.db.create_session(session_data, owner_id)
|
||||
|
||||
def get(self, session_id: str, owner_id: str) -> TranslationSession | None:
|
||||
result = self.get_with_version(session_id, owner_id)
|
||||
|
|
@ -40,18 +28,15 @@ class SessionStore:
|
|||
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:
|
||||
result = self.db.get_session(session_id, owner_id)
|
||||
if not result:
|
||||
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"]
|
||||
session, version, _ = result
|
||||
with self._progress_lock:
|
||||
progress = self._progress.get((owner_id, session_id))
|
||||
if progress:
|
||||
session = session.model_copy(update={"progress": PhaseProgress(**progress)})
|
||||
return session, version
|
||||
|
||||
def update(
|
||||
self,
|
||||
|
|
@ -59,53 +44,68 @@ class SessionStore:
|
|||
data: dict[str, Any],
|
||||
owner_id: str,
|
||||
expected_version: int | None = None,
|
||||
*,
|
||||
snapshot_reason: str | None = None,
|
||||
current_phase: int | None = None,
|
||||
) -> TranslationSession | None:
|
||||
entry = self._store.get(session_id)
|
||||
if entry is None or entry["owner_id"] != owner_id:
|
||||
result = self.db.update_session(
|
||||
session_id,
|
||||
owner_id,
|
||||
data,
|
||||
expected_version=expected_version,
|
||||
snapshot_reason=snapshot_reason,
|
||||
current_phase=current_phase,
|
||||
)
|
||||
if not result:
|
||||
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"])
|
||||
self.set_progress(session_id, None, owner_id)
|
||||
return result[0]
|
||||
|
||||
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
|
||||
self.set_progress(session_id, None, owner_id)
|
||||
return self.db.delete_session(session_id, owner_id)
|
||||
|
||||
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
|
||||
key = (owner_id, session_id)
|
||||
with self._progress_lock:
|
||||
if progress is None:
|
||||
self._progress.pop(key, None)
|
||||
else:
|
||||
self._progress[key] = progress
|
||||
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]
|
||||
def list(self, owner_id: str, include_archived: bool = False, limit: int = 50):
|
||||
return self.db.list_sessions(
|
||||
owner_id, include_archived=include_archived, limit=limit
|
||||
)
|
||||
|
||||
def metadata(self, session_id: str, owner_id: str) -> dict[str, Any] | None:
|
||||
result = self.db.get_session(session_id, owner_id)
|
||||
return result[2] if result else None
|
||||
|
||||
def rename(self, session_id: str, owner_id: str, title: str) -> bool:
|
||||
return self.db.rename_session(session_id, owner_id, title)
|
||||
|
||||
def archive(self, session_id: str, owner_id: str, archived: bool = True) -> bool:
|
||||
return self.db.set_archived(session_id, owner_id, archived)
|
||||
|
||||
def clone(self, session_id: str, owner_id: str) -> str | None:
|
||||
return self.db.clone_session(session_id, owner_id)
|
||||
|
||||
def get_revision(self, session_id: str, owner_id: str):
|
||||
return self.db.get_revision(session_id, owner_id)
|
||||
|
||||
def restore_revision(self, session_id: str, owner_id: str) -> bool:
|
||||
return self.db.restore_revision(session_id, owner_id)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
session_store = SessionStore(ttl_hours=24)
|
||||
session_store = SessionStore()
|
||||
|
||||
__all__ = [
|
||||
"SessionArchivedError",
|
||||
"SessionConflictError",
|
||||
"SessionStore",
|
||||
"session_store",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue