diff --git a/backend/cache_manager.py b/backend/cache_manager.py index 3571777..558121a 100644 --- a/backend/cache_manager.py +++ b/backend/cache_manager.py @@ -8,6 +8,34 @@ _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) @@ -16,14 +44,30 @@ def _make_key(prefix: str, args: tuple) -> str: 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] - value = loader() - with _LOCK: - _CACHE[key] = (now, value) - return value + + # 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():