⚠ Doubt-Driven Development

NutriSync — Revisión Adversarial: Capa de Caché

Decisión arquitectónica bajo duda — Cache en memoria para planes nutricionales | CULTIVA IA / IA-Ingeniería-MLOps
2 hallazgos críticos 1 trade-off documentado Artefacto corregido · Ciclo 1 de 3 NutriSync SaaS B2B · FastAPI + Gunicorn
CICLO 1 Revisión adversarial de la propuesta de caché en memoria para GET /api/v1/plans/{patient_id} Duración estimada: ~8 min · Stop: hallazgos triviales en ciclo 2
Checklist del ciclo de duda
  • Paso 1: CLAIM — decisión nombrada con su impacto
  • Paso 2: EXTRACT — artefacto + contrato aislados, sin razonamiento
  • Paso 3: DOUBT — revisor con prompt adversarial invocado
  • Paso 4: RECONCILE — cada hallazgo clasificado contra el texto del artefacto
  • Paso 5: STOP — condición de parada alcanzada (ciclo 2 sin hallazgos sustantivos)
1
CLAIM — Superficie de la decisión CLAIM
Claim

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.

Por qué importa

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.

2
EXTRACT — Artefacto y contrato mínimo EXTRACT
Artefacto — nutrition/cache.py (propuesta original)
# 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)

Contrato (pasado al revisor — sin razonamiento del autor)
  • C1Thread-safe bajo 50+ workers Gunicorn multi-proceso concurrentes.
  • C2Aislamiento total: paciente de clínica A nunca expone datos a clínica B.
  • C3Invalidación garantizada cuando un dietista actualiza el plan.
  • C4TTL máximo 5 minutos para datos sensibles de salud.
  • C5Compatible con despliegue multi-instancia en Railway.

⚠ Nota: el CLAIM no fue pasado al revisor. Solo ARTIFACT + CONTRACT.

3
DOUBT — Revisor adversarial (fresh context) DOUBT
Prompt adversarial enviado al revisor
Prompt adversarial
Revisión adversarial. Encuentra qué está MAL en este artefacto.
Asume que el autor es sobreconfiado. Busca:
- Suposiciones no declaradas
- Casos límite no manejados
- Acoplamiento oculto o estado compartido
- Formas en que el contrato podría violarse
- Modos de fallo bajo entrada inesperada

NO valides. NO resumas. Encuentra problemas, o declara explícitamente que no puedes encontrar ninguno tras un examen minucioso.

ARTIFACT: [nutrition/cache.py — ver arriba]
CONTRACT: [C1..C5 — ver arriba]

Hallazgos del revisor (raw — sin clasificar)
F1 · CRÍTICO threading.Lock() es inútil entre procesos Gunicorn — viola C1 y C3

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.

F2 · CRÍTICO Fuga cross-instancia en Railway — viola C5 y potencialmente C2

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.

F3 · MAYOR _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.

F4 · MODERADO 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.

F5 · RUIDO 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.


🔄 Cross-model ofrecido: "¿Quieres una segunda opinión de Gemini CLI o Codex CLI?" — Skipped: deadline urgente, equipo optó por continuar con hallazgos actuales. Reconocido en output.

Procediendo con hallazgos de modelo único solamente.

4
RECONCILE — Clasificación de hallazgos contra el artefacto RECONCILE
2
Válidos · Accionables
1
Válidos · Accionables (mayor)
1
Trade-off documentado
1
Ruido (no accionable)
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.

Artefacto corregido — nutrition/cache.py (ciclo 1)
# 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))
Cambios realizados vs propuesta original
  • Eliminado threading.Lock() y _plan_cache: dict — reemplazados por Redis (F1, F3)
  • Key tipada con int() explícito — evita colisión de tipos (F2)
  • Serialización JSON en set/get — copia implícita de datos (F4 resuelto como trade-off)
  • EX=300 en Redis — TTL y eviction automáticos (F3)
  • Dead import lru_cache eliminado (F5)
5
STOP — Condición de parada STOP

Se inició ciclo 2 sobre el artefacto corregido. El revisor examinó cache.py v2 y no encontró hallazgos sustantivos:

  • Redis es thread-safe y process-safe por diseño de su cliente — no requiere Lock adicional
  • JSON serialization es segura para tipos básicos (los planes nutricionales son dicts con strings/numbers)
  • La key con int() explícito previene colisiones
  • TTL de Redis respeta C4 exactamente

Condició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.

✓ APROBADO PARA COMMIT
Ciclos: 2 de 3 máx
Cross-model: skipped (deadline — usuario autorizó)
Trade-offs documentados: 1
Verificación post-ciclo — checklist de calidad
  • Toda decisión no trivial nombrada explícitamente como CLAIM antes de quedar asentada
  • Al menos una revisión fresh-context por artefacto no trivial
  • El revisor recibió ARTIFACT + CONTRACT — NO el CLAIM, NO el razonamiento del autor
  • Prompt del revisor adversarial ("encuentra problemas"), no validador ("¿está bien?")
  • Hallazgos clasificados contra el texto del artefacto (no rubber-stamping)
  • Condición de parada alcanzada: ciclo 2 sin hallazgos sustantivos
  • Cross-model ofrecido explícitamente al usuario en sesión interactiva
  • Cross-model skipped: deadline urgente, usuario autorizó explícitamente saltar
  • Skip de cross-model reconocido en output (no silencioso)
CULTIVA IA — Skill: desarrollo-basado-en-duda  ·  IA-Ingeniería-MLOps  ·  NutriSync SaaS B2B (caso ejemplo)