Files
dashboard-leads/backend/cache_manager.py

144 lines
4.8 KiB
Python

# 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")