# backend/cache_manager.py """Caché global en memoria con refresco en segundo plano (igual que el dashboard de ventas).""" 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 # ── Lock POR CLAVE (evita "estampida"): si varias peticiones piden el MISMO dato # vencido al mismo tiempo, solo la PRIMERA ejecuta la consulta pesada; las demas # esperan ese resultado en vez de lanzar su propia copia (que saturaba la base y # hacia que todo se sintiera colgado). Cada clave tiene su propio candado. _KEY_LOCKS: Dict[str, threading.Lock] = {} _KEY_LOCKS_GUARD = threading.Lock() def _lock_de(key: str) -> threading.Lock: with _KEY_LOCKS_GUARD: lk = _KEY_LOCKS.get(key) if lk is None: lk = threading.Lock() _KEY_LOCKS[key] = lk return lk # ── Modo "recarga en 2do plano" ──────────────────────────────────────────── # Mientras esta activo (solo lo activa el refresco de los 30 min, en su hilo), # cache_get_or_set RECALCULA cada dato con info fresca y recien al terminar # reemplaza la entrada. Asi la cache nunca queda vacia: el usuario sigue viendo # los datos viejos hasta que TODO lo nuevo esta listo (swap = 1 parpadeo). _FORZAR = threading.local() def _forzar_activo() -> bool: return getattr(_FORZAR, "on", False) def set_forzar(on: bool) -> None: _FORZAR.on = on 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: key = _make_key(prefix, args) now = time.time() if _forzar_activo(): # Refresco en 2do plano: recalcula y sobrescribe SIN pasar por el lock de # usuario (no debe esperar a nadie ni bloquear a los que leen la cache). value = loader() with _LOCK: _CACHE[key] = (time.time(), value) return value # Lectura normal: si esta fresco, devolver YA. with _LOCK: hit = _CACHE.get(key) if hit and (now - hit[0] < ttl): return hit[1] # Vencido/ausente: solo UNA peticion calcula; las demas esperan (lock x clave). with _lock_de(key): with _LOCK: hit = _CACHE.get(key) if hit and (time.time() - hit[0] < ttl): return hit[1] value = loader() with _LOCK: _CACHE[key] = (time.time(), value) return value def cache_keys(): with _LOCK: return list(_CACHE.keys()) def cache_refresh_existing(loader_for_key): 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: 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())} _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): 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")