# backend/cache_manager.py """ Caché global en memoria con refresco en segundo plano. Independiente de Streamlit. Mantiene los datos calientes para respuestas instantáneas. """ import time import threading from typing import Any, Callable, Dict, Tuple _CACHE: Dict[str, Tuple[float, Any]] = {} _LOCK = threading.Lock() _DEFAULT_TTL = 900 # 15 min def _make_key(prefix: str, args: tuple) -> str: return prefix + ":" + "|".join(str(a) for a in args) def cache_get_or_set(prefix: str, args: tuple, loader: Callable[[], Any], ttl: int = _DEFAULT_TTL) -> Any: """Devuelve el valor cacheado si está fresco; si no, lo calcula y guarda.""" key = _make_key(prefix, args) now = time.time() with _LOCK: hit = _CACHE.get(key) if hit and (now - hit[0] < ttl): return hit[1] # Calcular fuera del lock (puede tardar) value = loader() with _LOCK: _CACHE[key] = (now, value) return value def cache_set(prefix: str, args: tuple, value: Any) -> None: key = _make_key(prefix, args) with _LOCK: _CACHE[key] = (time.time(), value) def cache_keys(): """Lista las claves actualmente cacheadas (para refrescarlas sin vaciar).""" with _LOCK: return list(_CACHE.keys()) def cache_refresh_existing(loader_for_key): """Recalcula SOLO las entradas que ya existen, sin vaciar el caché. Así los meses ya visitados se mantienen calientes y nunca quedan 'fríos'.""" for key in cache_keys(): try: nuevo = loader_for_key(key) if nuevo is not None: with _LOCK: _CACHE[key] = (time.time(), nuevo) except Exception as e: print(f"[cache refresh] {key}: {e}") def cache_invalidate(prefix: str = None) -> None: """Invalida todo el caché, o solo las claves de un prefijo.""" with _LOCK: if prefix is None: _CACHE.clear() else: for k in list(_CACHE.keys()): if k.startswith(prefix + ":"): del _CACHE[k] def cache_stats() -> dict: with _LOCK: return {"entradas": len(_CACHE), "claves": list(_CACHE.keys())} # ── Refresco en segundo plano ────────────────────────────────────────────── _background_started = False def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 240): """Lanza un hilo que ejecuta refresh_fn cada `interval` segundos (4 min por defecto, antes de que expire el TTL de 5 min, para mantener el caché siempre caliente).""" global _background_started if _background_started: return _background_started = True def _loop(): while True: time.sleep(interval) try: refresh_fn() except Exception as e: print(f"[background refresh] error: {e}") t = threading.Thread(target=_loop, daemon=True) t.start() print(f"[cache] refresco en segundo plano cada {interval}s iniciado")