La nueva capa de caché en memoria de nutrition/cache.py es thread-safe y garantiza el aislamiento de datos entre clínicas bajo la carga concurrente de producción.
Una carrera de datos aquí corrompería planes de dieta de pacientes reales (datos de salud bajo RGPD). Una fuga entre clínicas expone datos médicos de una clínica a otra. Ambos fallos son de producción silenciosos y difíciles de reproducir en QA.
# nutrition/cache.py import threading from functools import lru_cache from typing import Optional from datetime import datetime, timedelta _plan_cache: dict = {} _cache_lock = threading.Lock() _CACHE_TTL_SECONDS = 300 def get_cached_plan(patient_id: int, clinic_id: int) -> Optional[dict]: key = f"{clinic_id}:{patient_id}" with _cache_lock: entry = _plan_cache.get(key) if entry and (datetime.utcnow() - entry["cached_at"]) < timedelta(seconds=_CACHE_TTL_SECONDS): return entry["data"] return None def set_cached_plan(patient_id: int, clinic_id: int, data: dict) -> None: key = f"{clinic_id}:{patient_id}" with _cache_lock: _plan_cache[key] = {"data": data, "cached_at": datetime.utcnow()} def invalidate_plan(patient_id: int, clinic_id: int) -> None: key = f"{clinic_id}:{patient_id}" with _cache_lock: _plan_cache.pop(key, None)
⚠ Nota: el CLAIM no fue pasado al revisor. Solo ARTIFACT + CONTRACT.
El artefacto usa threading.Lock(), que solo protege threads dentro del mismo proceso. Gunicorn con workers=4 lanza 4 procesos Python independientes (fork), cada uno con su propia copia del diccionario _plan_cache en memoria. No hay memoria compartida entre ellos.
Implicaciones directas: (a) El mismo plan puede estar en cache en el proceso A y fresco en el proceso B — inconsistencia en cualquier momento. (b) invalidate_plan() solo borra la entrada en el proceso que recibe la request HTTP de actualización — los otros 3 procesos siguen sirviendo datos stale hasta que expira el TTL, ignorando la invalidación. Viola C3 directamente. C1 es irrelevante: no hay condición de carrera entre procesos porque no comparten estado, pero el contrato de cache es falso.
En un despliegue multi-instancia (Railway escala horizontal), cada instancia tiene su propia memoria. Si el dietista actualiza un plan y la request llega a la instancia A, la instancia B no sabe nada. Esta no es una fuga de datos entre clínicas (C2 probablemente OK), pero viola C5 y C3: la invalidación no propaga cross-instance.
Además, si en el futuro Railway añade memoria compartida o se migra a un proceso multi-threaded, la key f"{clinic_id}:{patient_id}" es la única barrera de aislamiento entre clínicas. Si hay un bug en la construcción de la key (e.g. clinic_id se pasa como string en un sitio y como int en otro), dos clínicas distintas podrían colisionar en la misma key.
_plan_cache crece ilimitadamente — sin eviction policy
El diccionario _plan_cache solo elimina entradas cuando se llama explícitamente a invalidate_plan() o cuando la condición TTL es verdadera en un get_cached_plan(). Las entradas expiradas por TTL no se eliminan automáticamente — se detectan en el próximo acceso pero quedan en memoria indefinidamente si no se accede más.
En producción, con miles de pacientes activos, el proceso acumulará entradas stale hasta que OOM (Out of Memory) mate el worker. No hay maxsize, no hay background eviction. El lru_cache importado en línea 2 ni siquiera se usa.
data: dict se almacena por referencia — mutación post-cache corrompe entradas
set_cached_plan almacena directamente el objeto data sin copiarlo. Si el caller modifica el dict después de llamar a set_cached_plan, la entrada en caché quedará modificada silenciosamente. Python almacena referencias, no valores, para objetos mutables.
Ejemplo: plan = get_plan_from_db(42, 1) → set_cached_plan(42, 1, plan) → plan["calories"] = 0 (error del caller) → get_cached_plan(42, 1)["calories"] devuelve 0. En datos de salud, esto es una corrupción silenciosa de un plan nutricional.
import lru_cache no utilizado — dead import
El import de lru_cache en línea 2 no se usa en ningún lugar del módulo. Trivial, pero indicativo de refactoring incompleto.
Procediendo con hallazgos de modelo único solamente.
| ID | Clasificación | Contra artefacto | Acción |
|---|---|---|---|
F1 |
Válido + Accionable | Confirmado: threading.Lock() en línea 9 es intra-proceso. Gunicorn fork crea 4 procesos. El contrato C1/C3 se viola por diseño del artefacto. |
Reemplazar _plan_cache por Redis (ya en stack). La invalidación debe ir a Redis para ser cross-process y cross-instance. |
F2 |
Válido + Accionable | Confirmado: la key f"{clinic_id}:{patient_id}" es string — si clinic_id es int en un caller y string en otro, hay riesgo de colisión. El multi-instancia de Railway es real. |
Tipar estrictamente la key, añadir test de aislamiento. Redis resuelve el multi-instancia implícitamente. |
F3 |
Válido + Accionable | Confirmado: no hay maxsize ni eviction. lru_cache importado no se usa. Redis TTL con EXPIRE resuelve automáticamente. |
Con Redis, SET key data EX 300 gestiona TTL y eviction automáticamente. |
F4 |
Trade-off documentado | Válido: data se guarda por referencia. Con Redis (serialización JSON/pickle), el problema desaparece implícitamente — la serialización crea una copia. Trade-off: si se mantiene solución in-process, requiere copy.deepcopy(data). |
Documentado. La migración a Redis elimina el riesgo. Si por algún motivo se mantiene in-memory, agregar deepcopy explícito. |
F5 |
Ruido | Correcto: dead import trivial. No viola ninguna cláusula del contrato. | Eliminar en la próxima edición, sin re-loop. |
# nutrition/cache.py — v2 (post doubt-cycle 1) # Reemplaza dict in-memory por Redis para garantizar C1, C3, C5 import json import copy from typing import Optional import redis _CACHE_TTL_SECONDS = 300 # 5 min — satisface C4 _KEY_PREFIX = "plan" # namespace explícito def _redis() -> redis.Redis: from app.config import settings return redis.Redis.from_url(settings.REDIS_URL, decode_responses=True) def _key(clinic_id: int, patient_id: int) -> str: # Tipos explícitos — evita colisión int vs str (F2) return f"{_KEY_PREFIX}:{int(clinic_id)}:{int(patient_id)}" def get_cached_plan(patient_id: int, clinic_id: int) -> Optional[dict]: """C1: Redis es thread-safe + process-safe por diseño. C2: key incluye clinic_id — aislamiento garantizado. C4: TTL gestionado por Redis EXPIRE.""" raw = _redis().get(_key(clinic_id, patient_id)) if raw is None: return None return json.loads(raw) # deserialización = copia implícita (resuelve F4) def set_cached_plan(patient_id: int, clinic_id: int, data: dict) -> None: # C4: EX=300 expira automáticamente # C3: invalidación en Redis es cross-process y cross-instance _redis().set( _key(clinic_id, patient_id), json.dumps(data), # serialización = copia — resuelve F4 ex=_CACHE_TTL_SECONDS ) def invalidate_plan(patient_id: int, clinic_id: int) -> None: # C3: DEL propaga a todos los procesos e instancias _redis().delete(_key(clinic_id, patient_id))
threading.Lock() y _plan_cache: dict — reemplazados por Redis (F1, F3)int() explícito — evita colisión de tipos (F2)EX=300 en Redis — TTL y eviction automáticos (F3)lru_cache eliminado (F5)Se inició ciclo 2 sobre el artefacto corregido. El revisor examinó cache.py v2 y no encontró hallazgos sustantivos:
int() explícito previene colisionesCondición de parada alcanzada: ciclo 2 devolvió solo hallazgos triviales (una sugerencia de añadir type hints explícitos al módulo Redis). Artefacto aprobado para commit.