# 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 = 1800 # 30 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 _en_horario_refresco(ahora): """Reglas de auto-refresco (hora local del servidor): - Madrugada (antes de 07:30 o desde 22:30): NO (solo manual). - Domingo: solo de 08:00 a 14:00. - Lun-Sab: de 07:30 a 22:30.""" minutos = ahora.hour * 60 + ahora.minute dow = ahora.weekday() # 0=lunes ... 6=domingo if dow == 6: # domingo: 08:00 - 14:00 return 8 * 60 <= minutos < 14 * 60 return 7 * 60 + 30 <= minutos < 22 * 60 + 30 # lun-sab: 07:30 - 22:30 def start_background_refresh(refresh_fn: Callable[[], None], interval: int = 1800): """Lanza un hilo que ejecuta refresh_fn cada `interval` segundos (30 min por defecto), pero SOLO dentro del horario permitido (_en_horario_refresco). Fuera de horario no auto-actualiza (el usuario aún puede refrescar manualmente con el botón).""" global _background_started if _background_started: return _background_started = True def _loop(): from datetime import datetime ultimo = 0.0 while True: time.sleep(60) # chequear cada minuto para respetar los horarios ahora = datetime.now() if not _en_horario_refresco(ahora): continue # fuera de horario -> no auto-refresca if (time.time() - ultimo) < interval: continue # aún no pasan 'interval' seg desde el último try: refresh_fn() ultimo = time.time() 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 (con horario) iniciado")