""" app/session_store.py — Redis abstraction with in-memory fallback. V2 Fix: V1 used a plain dict — sessions lost on restart. V2 uses Upstash Redis (free tier). If Redis is unavailable, falls back to an in-memory dict so the episode never crashes. Worst case: sessions are process-local again, same as V1. The rest of the codebase never touches Redis directly — only load/save/delete. """ import os import pickle from typing import Optional # ── Lazy Redis client ──────────────────────────────────────────────────────── _redis_client = None _local_cache: dict = {} # In-memory fallback — activated when Redis is down REDIS_URL = os.getenv("REDIS_URL", "") SESSION_TTL = 3600 # 1 hour — episodes expire after inactivity def _get_redis(): """Lazy singleton. Returns Redis client or None if unavailable.""" global _redis_client if _redis_client is not None: return _redis_client if not REDIS_URL: return None try: import redis as redis_lib _redis_client = redis_lib.from_url(REDIS_URL, decode_responses=False, socket_timeout=2) _redis_client.ping() # Fail fast if connection is broken return _redis_client except Exception: return None def load(session_id: str): """Fetch EpisodeState from Redis, fall back to local cache.""" key = f"session:{session_id}" r = _get_redis() if r: try: data = r.get(key) return pickle.loads(data) if data else None except Exception: pass # Fallback: local memory return _local_cache.get(session_id) def save(session_id: str, state) -> None: """Persist EpisodeState to Redis + local cache (dual write for resilience).""" key = f"session:{session_id}" _local_cache[session_id] = state # Always write locally r = _get_redis() if r: try: r.setex(key, SESSION_TTL, pickle.dumps(state)) except Exception: pass # Redis outage — local cache is the fallback def delete(session_id: str) -> None: """Remove session after episode completes.""" _local_cache.pop(session_id, None) r = _get_redis() if r: try: r.delete(f"session:{session_id}") except Exception: pass