"""Almacén en proceso de resultados de analizador, emparejados por ID de muestra. Segmentado por TENANT: cada resultado lleva la clínica dueña y las lecturas la exigen. Antes era un dict global y cualquier sesión autenticada podía leer la muestra de cualquier clínica. Deliberadamente NO es SQLite: HF Spaces tiene disco efímero, un único worker de uvicorn, y los resultados son de vida corta (se emparejan con el formulario en minutos). Un dict con TTL + tope LRU es la primitiva correcta. Clave normalizada (trim + minúsculas) en lectura y escritura. Si algún día se añaden workers, este almacén deja de ser correcto y hay que moverlo a SQLite/caché compartida (ver `lab_persistir` en config). """ from __future__ import annotations import threading import time from collections import OrderedDict from ..schemas_lab import ResultadoMapeado TTL_SEGUNDOS = 24 * 3600 MAX_ENTRADAS = 500 def _clave(muestra_id: str) -> str: return muestra_id.strip().lower() class AlmacenResultados: def __init__(self, ttl: int = TTL_SEGUNDOS, max_entradas: int = MAX_ENTRADAS) -> None: self._ttl = ttl self._max = max_entradas self._lock = threading.Lock() # clave → (instante_monotónico, resultado). OrderedDict para desalojo LRU. self._datos: OrderedDict[str, tuple[float, ResultadoMapeado]] = OrderedDict() def guardar(self, res: ResultadoMapeado) -> None: with self._lock: k = _clave(res.muestra_id) self._datos[k] = (time.monotonic(), res) # último gana self._datos.move_to_end(k) self._barrer_locked() while len(self._datos) > self._max: self._datos.popitem(last=False) # desaloja el más antiguo def obtener(self, muestra_id: str, tenant: str) -> ResultadoMapeado | None: """Resultado de ESE tenant, o None. Un ID de otra clínica se comporta como inexistente. El tenant es obligatorio a propósito: si fuera opcional, olvidarlo en una llamada nueva devolvería datos de todas las clínicas en silencio, que es justo el fallo que esto cierra. """ with self._lock: self._barrer_locked() item = self._datos.get(_clave(muestra_id)) if item is None or item[1].tenant != tenant: return None return item[1] def pendientes(self, tenant: str) -> list[ResultadoMapeado]: """Cola de ESE tenant, más recientes primero.""" with self._lock: self._barrer_locked() return [r for (_, r) in reversed(self._datos.values()) if r.tenant == tenant] def _barrer_locked(self) -> None: ahora = time.monotonic() expiradas = [k for k, (t, _) in self._datos.items() if ahora - t > self._ttl] for k in expiradas: del self._datos[k] # Singleton de módulo importado por routers/lab.py. almacen = AlmacenResultados()