# Patrones Async de Producción — FastAPI + SQLAlchemy

## El anti-patrón MissingGreenlet — explicación y solución

`MissingGreenlet` es el error más frecuente en SQLAlchemy async. Ocurre cuando
SQLAlchemy intenta resolver una relación lazy **fuera del contexto async**,
generalmente al serializar el objeto con Pydantic después de que la sesión ya se cerró.

```python
# ERROR — lazy loading implícito post-sesión
async def obtener_factura(factura_id: str, db: AsyncSession = Depends(get_db)):
    factura = await db.get(Factura, factura_id)
    return factura  # Pydantic intenta acceder a factura.cliente — sesión ya cerrada

# ERROR — acceso a relación sin cargar explícitamente
factura = await db.get(Factura, factura_id)
nombre = factura.cliente.nombre   # MissingGreenlet aquí

# SOLUCIÓN — selectinload explícito SIEMPRE
from sqlalchemy.orm import selectinload

result = await db.execute(
    select(Factura)
    .where(Factura.id == factura_id)
    .options(selectinload(Factura.cliente))  # Carga en la misma query async
)
factura = result.scalar_one_or_none()
nombre = factura.cliente.nombre  # Seguro — ya está cargado
```

### Prevención a nivel de modelo

Configurar `lazy="raise"` en relaciones que NO deben cargarse automáticamente
hace que el error aparezca en desarrollo, no en producción.

```python
class Factura(Base):
    __tablename__ = "facturas"

    # lazy="selectin" — se carga siempre, accesible sin selectinload explícito
    cliente: Mapped["Cliente"] = relationship(lazy="selectin")

    # lazy="raise" — falla inmediatamente si se accede sin selectinload
    # Útil para detectar accesos no intencionales en desarrollo
    items: Mapped[list["ItemFactura"]] = relationship(lazy="raise")
    auditoria: Mapped[list["Auditoria"]] = relationship(lazy="raise")
```

---

## selectinload vs joinedload vs subqueryload en async

| Estrategia | Cuándo usar | Genera |
|------------|-------------|--------|
| `selectinload` | Relaciones 1:N o N:1. Recomendado en async | 2 queries separadas |
| `joinedload` | Solo relaciones N:1 con pocos registros | JOIN en la query principal |
| `subqueryload` | Evitar en async — puede causar problemas | Subquery correlacionada |

```python
from sqlalchemy.orm import selectinload, joinedload, contains_eager

# selectinload — el patrón correcto para async
result = await db.execute(
    select(Factura)
    .options(
        selectinload(Factura.items).selectinload(ItemFactura.producto),  # Anidado
        selectinload(Factura.cliente),
    )
)

# joinedload — solo para relaciones N:1 donde el JOIN es conveniente
result = await db.execute(
    select(Factura)
    .join(Factura.cliente)
    .options(contains_eager(Factura.cliente))   # Usa el JOIN existente
    .where(Cliente.pais == "MX")
)

# NUNCA usar lazy="joined" en modelos async — causa N+1 silencioso
# class Factura(Base):
#     cliente = relationship(lazy="joined")   # MAL en async
```

---

## Background Tasks: BackgroundTasks vs Celery vs ARQ

### BackgroundTasks — para tareas ligeras en el mismo proceso

```python
from fastapi import BackgroundTasks
import structlog

logger = structlog.get_logger()

async def _enviar_confirmacion(email: str, folio: str) -> None:
    """Tarea interna. No re-raise — no debe crashear la app."""
    try:
        await servicio_email.enviar(
            destinatario=email,
            asunto=f"Factura {folio} emitida",
            plantilla="confirmacion_factura",
        )
        logger.info("Confirmación enviada", email=email, folio=folio)
    except Exception as exc:
        logger.error("Error enviando confirmación", email=email, exc_info=exc)
        # NO re-raise — BackgroundTasks no tiene manejo de errores externo


@router.post("/facturas/{factura_id}/emitir", response_model=FacturaRead)
async def emitir_factura(
    factura_id: str,
    background_tasks: BackgroundTasks,
    db: DbDep,
    usuario: UsuarioActualDep,
) -> FacturaRead:
    factura = await servicio.emitir(db, factura_id)
    # La tarea se ejecuta DESPUÉS de retornar la respuesta
    background_tasks.add_task(_enviar_confirmacion, usuario.email, factura.folio)
    return factura
```

**Limitaciones de BackgroundTasks**: sin reintentos, sin persistencia, sin monitoreo.
Usar solo para tareas que pueden fallar sin consecuencias graves.

### Celery — para tareas persistentes con reintentos

Ver la sección de Celery en la documentación principal del sistema SWL. Usar cuando:
se necesitan reintentos automáticos, colas separadas por prioridad, o la tarea puede
tardar más de 30 segundos.

### ARQ — alternativa async pura a Celery

```python
# workers/arq_worker.py
from arq import create_pool
from arq.connections import RedisSettings

async def enviar_reporte(ctx: dict, usuario_id: str, periodo: str) -> dict:
    """Tarea ARQ — async nativa, sin Celery."""
    db: AsyncSession = ctx["db"]
    reporte = await generar_reporte(db, usuario_id, periodo)
    await servicio_email.enviar_reporte(reporte)
    return {"status": "enviado", "filas": reporte.total_filas}


class WorkerSettings:
    functions = [enviar_reporte]
    redis_settings = RedisSettings(host=settings.REDIS_HOST)
    max_jobs = 10
    job_timeout = 300  # 5 minutos máximo por tarea


# Encolar desde un endpoint
async def encolar_reporte(usuario_id: str) -> str:
    redis = await create_pool(RedisSettings(host=settings.REDIS_HOST))
    job = await redis.enqueue_job("enviar_reporte", usuario_id, "2024-Q1")
    return job.job_id
```

---

## WebSockets con FastAPI — chat completo

```python
# app/routers/chat.py
import asyncio
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Any

router = APIRouter()


class SalaChat:
    """Gestiona conexiones WebSocket activas por sala."""

    def __init__(self) -> None:
        # sala_id -> {client_id -> WebSocket}
        self._salas: dict[str, dict[str, WebSocket]] = {}

    async def unirse(self, sala_id: str, client_id: str, ws: WebSocket) -> None:
        await ws.accept()
        if sala_id not in self._salas:
            self._salas[sala_id] = {}
        self._salas[sala_id][client_id] = ws

    def salir(self, sala_id: str, client_id: str) -> None:
        sala = self._salas.get(sala_id, {})
        sala.pop(client_id, None)
        if not sala:
            self._salas.pop(sala_id, None)

    async def broadcast_sala(self, sala_id: str, mensaje: dict[str, Any]) -> None:
        sala = self._salas.get(sala_id, {})
        desconectados: list[str] = []
        texto = json.dumps(mensaje, ensure_ascii=False)
        for cid, ws in sala.items():
            try:
                await ws.send_text(texto)
            except Exception:
                desconectados.append(cid)
        for cid in desconectados:
            self.salir(sala_id, cid)


sala_manager = SalaChat()


async def _heartbeat(ws: WebSocket, intervalo: int = 30) -> None:
    """Ping periódico para detectar conexiones muertas."""
    while True:
        await asyncio.sleep(intervalo)
        try:
            await ws.send_text('{"tipo":"ping"}')
        except Exception:
            break  # Conexión muerta — terminar coroutine


@router.websocket("/chat/{sala_id}/{usuario_id}")
async def endpoint_chat(
    websocket: WebSocket,
    sala_id: str,
    usuario_id: str,
) -> None:
    await sala_manager.unirse(sala_id, usuario_id, websocket)
    heartbeat = asyncio.create_task(_heartbeat(websocket))
    try:
        while True:
            texto = await websocket.receive_text()
            mensaje = json.loads(texto)
            await sala_manager.broadcast_sala(sala_id, {
                "tipo": "mensaje",
                "autor": usuario_id,
                "contenido": mensaje.get("contenido", ""),
            })
    except WebSocketDisconnect:
        pass
    finally:
        heartbeat.cancel()
        sala_manager.salir(sala_id, usuario_id)
        await sala_manager.broadcast_sala(sala_id, {
            "tipo": "sistema",
            "contenido": f"{usuario_id} salió de la sala",
        })
```

---

## Server-Sent Events (SSE) con StreamingResponse

```python
# app/routers/notificaciones.py
import asyncio
import json
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from typing import AsyncGenerator

router = APIRouter()


async def _stream_notificaciones(
    usuario_id: str,
    db: AsyncSession,
) -> AsyncGenerator[str, None]:
    """Genera eventos SSE. Envía keep-alive cada 15s para evitar timeout."""
    try:
        ultima_id = await obtener_ultima_notificacion_id(db, usuario_id)
        while True:
            nuevas = await obtener_notificaciones_nuevas(db, usuario_id, desde=ultima_id)
            for notif in nuevas:
                payload = json.dumps(notif.model_dump(), ensure_ascii=False)
                yield f"id: {notif.id}\ndata: {payload}\n\n"
                ultima_id = notif.id
            if not nuevas:
                # Keep-alive — evita que el proxy cierre la conexión inactiva
                yield ": keep-alive\n\n"
            await asyncio.sleep(2)
    except asyncio.CancelledError:
        pass  # Cliente desconectado — salida limpia, no loggear como error


@router.get("/notificaciones/stream")
async def stream_notificaciones(
    db: DbDep,
    usuario: UsuarioActualDep,
) -> StreamingResponse:
    return StreamingResponse(
        _stream_notificaciones(usuario.id, db),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",       # Desactiva buffer en Nginx
            "Connection": "keep-alive",
        },
    )
```

---

## Timeout y cancellation con asyncio.wait_for

```python
import asyncio
from fastapi import HTTPException

async def llamar_servicio_externo(url: str, payload: dict) -> dict:
    """Llama a un servicio externo con timeout estricto."""
    try:
        return await asyncio.wait_for(
            _hacer_peticion(url, payload),
            timeout=5.0,  # 5 segundos máximo
        )
    except asyncio.TimeoutError:
        raise HTTPException(
            status_code=504,
            detail="El servicio externo tardó demasiado en responder",
        )


# Timeout en endpoints — cancelar la tarea si el cliente se desconecta
@router.post("/procesar-documento")
async def procesar_documento(archivo: UploadFile, db: DbDep) -> dict:
    try:
        resultado = await asyncio.wait_for(
            servicio_ocr.procesar(await archivo.read()),
            timeout=30.0,
        )
        return resultado
    except asyncio.TimeoutError:
        raise HTTPException(status_code=408, detail="Procesamiento excedió el tiempo límite")
```

---

## Connection pooling con create_async_engine

```python
# app/db/session.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

engine = create_async_engine(
    settings.DATABASE_URL,        # postgresql+asyncpg://user:pass@host/db
    pool_size=10,                  # Conexiones permanentes en el pool
    max_overflow=20,               # Conexiones adicionales bajo carga alta
    pool_pre_ping=True,            # Verifica conexiones muertas antes de usar
    pool_recycle=3600,             # Recicla conexiones cada hora (evita timeouts)
    pool_timeout=30,               # Tiempo máximo esperando una conexión libre
    echo=settings.DEBUG,           # Loggea SQL en desarrollo
    echo_pool=settings.DEBUG,      # Loggea eventos del pool en desarrollo
    connect_args={
        "server_settings": {
            "application_name": settings.APP_NOMBRE,  # Visible en pg_stat_activity
        }
    },
)

AsyncSessionLocal = async_sessionmaker(
    engine,
    expire_on_commit=False,  # OBLIGATORIO — evita lazy loads post-commit en async
    autoflush=False,          # Control explícito de flush en services
    class_=AsyncSession,
)
```

---

## Evitar bloqueos en async: asyncio.to_thread

Código síncrono bloqueante dentro de una función `async` bloquea el event loop
completo. Usar `asyncio.to_thread` para ejecutarlo en un thread pool.

```python
import asyncio
from pathlib import Path

# MAL — bloquea el event loop mientras lee el archivo
async def leer_archivo_mal(ruta: Path) -> bytes:
    return ruta.read_bytes()   # I/O síncrono — bloquea

# BIEN — delega a thread pool, el event loop queda libre
async def leer_archivo(ruta: Path) -> bytes:
    return await asyncio.to_thread(ruta.read_bytes)

# BIEN — operaciones CPU-intensivas fuera del event loop
async def comprimir_pdf(datos: bytes) -> bytes:
    return await asyncio.to_thread(_comprimir_sincrono, datos)

def _comprimir_sincrono(datos: bytes) -> bytes:
    """Función síncrona que puede bloquearse sin problema en su propio thread."""
    import gzip
    return gzip.compress(datos, compresslevel=9)
```

---

## AsyncGenerator para streaming de respuestas grandes

```python
# app/routers/exportar.py
import csv
import io
from fastapi.responses import StreamingResponse

async def _generar_csv_facturas(
    db: AsyncSession,
    empresa_id: str,
) -> AsyncGenerator[bytes, None]:
    """Genera CSV por lotes de 1000 registros para no saturar memoria."""
    encabezado = io.StringIO()
    writer = csv.writer(encabezado)
    writer.writerow(["folio", "fecha", "cliente", "total", "estatus"])
    yield encabezado.getvalue().encode("utf-8-sig")  # BOM para Excel en Windows

    offset = 0
    lote = 1000
    while True:
        facturas = await obtener_facturas_lote(db, empresa_id, offset=offset, limit=lote)
        if not facturas:
            break
        buffer = io.StringIO()
        writer = csv.writer(buffer)
        for f in facturas:
            writer.writerow([f.folio, f.fecha, f.cliente.nombre, f.total, f.estatus])
        yield buffer.getvalue().encode("utf-8-sig")
        offset += lote
        await asyncio.sleep(0)  # yield al event loop entre lotes


@router.get("/facturas/exportar.csv")
async def exportar_csv(db: DbDep, usuario: UsuarioActualDep) -> StreamingResponse:
    return StreamingResponse(
        _generar_csv_facturas(db, usuario.empresa_id),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=facturas.csv"},
    )
```
