Saltar a contenido

Despliegue en producción

Esta página recoge todo lo que deberías considerar al ejecutar dorm fuera del portátil: pooling de conexiones, réplicas, reintentos, observabilidad y workflow de deploy.

Dimensionar el pool de conexiones

Settings de PostgreSQL (DATABASES["default"]):

Clave Default Notas
MIN_POOL_SIZE 1 conexiones idle a mantener abiertas
MAX_POOL_SIZE 10 tope duro; los checkouts adicionales esperan
POOL_TIMEOUT 30.0 segundos antes de que un checkout lance PoolTimeout
POOL_CHECK True SELECT 1 al checkout para descartar conexiones rotas
PREPARE_THRESHOLD default de psycopg (5) tras cuántas ejecuciones del mismo SQL psycopg lo prepara en el servidor. Pon 0 para "siempre preparar" en apps dominadas por queries repetidas; súbelo si tu workload genera muchas queries únicas
MAX_IDLE 600.0 recicla conexiones que llevan idle más de N segundos
MAX_LIFETIME 3600.0 recicla cada conexión tras N segundos, independientemente de la actividad

Regla de oro: MAX_POOL_SIZE = vCPU * 2 por proceso. Multiplica por el número de workers (gunicorn, uvicorn) para obtener el footprint total de conexiones, y asegúrate de que cabe en el max_connections de PostgreSQL con margen para slots de replicación y sesiones admin.

DATABASES = {
    "default": {
        "ENGINE": "postgresql",
        "NAME": "myapp",
        "USER": "myapp",
        "PASSWORD": "...",
        "HOST": "primary.internal",
        "PORT": 5432,
        "MIN_POOL_SIZE": 4,
        "MAX_POOL_SIZE": 20,
        "POOL_TIMEOUT": 10.0,
    }
}

Si estás detrás de PgBouncer en modo transaction, baja MIN_POOL_SIZE a 1 — el bouncer es el pool de verdad, dorm solo necesita checkouts baratos.

Réplicas de lectura

Define cada alias en DATABASES y enruta vía DATABASE_ROUTERS:

DATABASES = {
    "default": {"ENGINE": "postgresql", "HOST": "primary.internal", ...},
    "replica": {"ENGINE": "postgresql", "HOST": "replica.internal", ...},
}

class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):
        return "replica"
    def db_for_write(self, model, **hints):
        return "default"

DATABASE_ROUTERS = [PrimaryReplicaRouter()]

Los routers también pueden ramificar por modelo:

class AuditRouter:
    def db_for_write(self, model, **hints):
        if model._meta.app_label == "audit":
            return "audit_writer"
        return None      # deja que decidan otros routers / default

Para un override puntual, usa Manager.using("alias") — saltea los routers para esa única query.

Reintento ante errores transitorios

dorm reintenta OperationalError e InterfaceError (cortes de red, reinicio del servidor) tanto en pools sync como async. Tuneable vía variables de entorno:

Var Default Efecto
DORM_RETRY_ATTEMPTS 3 intentos totales incluyendo el primero
DORM_RETRY_BACKOFF 0.1 segundos, multiplicados por 2^intento

Los retries están desactivados dentro de transacciones — el pool no puede reproducir con seguridad un BEGIN a medio commitear. Envuelve secuencias externas "must-succeed" en tu propio bucle de retry con claves de idempotencia.

Health checks

import dorm

@app.get("/healthz")
async def healthz():
    return await dorm.ahealth_check()

health_check() (sync) y ahealth_check() (async) ejecutan un SELECT 1 sobre el alias configurado y devuelven:

{"status": "ok", "alias": "default", "elapsed_ms": 0.42}
{"status": "error", "alias": "default", "elapsed_ms": 5012.0,
 "error": "OperationalError: connection refused"}

Ninguno lanza excepción — siempre responden, incluso cuando la BD está caída, que es lo que necesita una sonda de readiness en un orquestador.

Pasa deep=True para incluir además estadísticas en vivo del pool, así el mismo endpoint puede servir readiness y observabilidad:

await dorm.ahealth_check(deep=True)
# {
#   "status": "ok", "alias": "default", "elapsed_ms": 0.42,
#   "pool": {
#     "alias": "default", "vendor": "postgresql", "has_pool": True,
#     "pool_min": 1, "pool_max": 10,
#     "pool_size": 7, "pool_available": 4, "requests_waiting": 0,
#     "requests_num": 18234, "usage_ms": 412.3, "connections_ms": 1.1,
#     ...
#   }
# }

O llama a dorm.pool_stats(alias) directamente si solo te interesa la vista del pool (p.ej. en un exporter de Prometheus):

from dorm.db.connection import pool_stats
stats = pool_stats("default")

Un pool cuyo pool_available se queda a cero con requests_waiting > 0 durante periodos prolongados es el indicador adelantado de una app limitada por conexiones.

Seguridad de migraciones: dorm lint-migrations

Audita cada migración en INSTALLED_APPS buscando footguns de deploy online. Sale con código != 0 ante hallazgos — engánchalo como gate de CI.

$ dorm lint-migrations
DORM-M001 myapp/migrations/0003_add_score.py: AddField with null=False and a default backfills every row...

1 finding(s).
Código Trigger Motivo
DORM-M001 AddField(null=False, default=…) backfill de toda la tabla
DORM-M002 AlterField (cualquiera) avisa en todo alter — revisa si cambia el tipo (reescribe tabla en PG / MySQL) o solo toggle NOT NULL / default
DORM-M003 AddIndex sin concurrently=True (PG) lock ACCESS EXCLUSIVE
DORM-M004 RunPython sin reverse_code migración irreversible

Silencia por archivo con # noqa: DORM-M00X. Salida JSON para CI: dorm lint-migrations --format=json.

Despliegue de migraciones

El orden recomendado:

  1. Construye el nuevo código (artifact inmutable).
  2. dorm migrate --dry-run contra producción — revisa el SQL.
  3. dorm migrate (los advisory locks hacen segura la ejecución concurrente).
  4. Despliega el código nuevo.

Para cambios de esquema sin downtime, sigue el playbook estándar expand/contract:

Paso Migración Código
Expand añade columna nullable el código viejo la ignora
Backfill data migration en chunks viejo y nuevo conviven
Contract NOT NULL, borra la vieja solo código nuevo

dorm dbcheck en tu CI caza el caso en el que un dev olvidó commitear una migración: sale non-zero ante drift.

Observabilidad

Hooks por query

from dorm.signals import pre_query, post_query

def trace(sender, sql, params, **kwargs):
    # ``sender`` es el string del vendor ("postgresql", "sqlite",
    # "libsql"). ``post_query`` añade ``elapsed_ms`` (float) y
    # ``error`` (None si fue OK).
    log.info(
        "query",
        sql=sql,
        params=params,
        vendor=sender,
        ms=kwargs.get("elapsed_ms"),
        error=kwargs.get("error"),
    )

pre_query.connect(trace)
post_query.connect(trace)

Conéctalos a OpenTelemetry, structlog, o lo que uses. La señal post_query incluye elapsed_ms, que es lo que querrás meter en tu APM. La señal NO carga el alias de DB hoy — receivers que lo necesiten deben mirar dorm.db.connection.get_connection().alias desde el contexto de llamada.

Stats del pool

from dorm.db.connection import get_connection
stats = get_connection("default").pool_stats()
# {"size": 7, "idle": 4, "in_use": 3, "max_size": 20, ...}

Expón esto en /metrics vía Prometheus para graficar la saturación. Un pool que toca in_use == max_size durante periodos sostenidos es el indicador líder de una app constreñida por conexiones.

EXPLAIN

Para debugging puntual, qs.explain(analyze=True) devuelve la salida del planner. Engánchalo a un endpoint solo-dev o úsalo en dorm shell.

Reintentos (RETRY_ATTEMPTS / RETRY_BACKOFF)

Misma resolución que SLOW_QUERY_MS — setting explícito > env var > default. Solo se reintenta FUERA de transacción (dentro reaplicaría trabajo ya commiteado). RETRY_ATTEMPTS=1 desactiva el bucle. Equivalentes en env: DORM_RETRY_ATTEMPTS / DORM_RETRY_BACKOFF.

dorm.configure(RETRY_ATTEMPTS=5, RETRY_BACKOFF=0.25)

Guardia de queries por bloque (QUERY_COUNT_WARN)

Guard ligero contra N+1: cuenta queries dentro de un context manager y emite un WARNING si supera el umbral. Combínalo con dorm.contrib.nplusone para observabilidad más completa.

from dorm.contrib.querycount import query_count_guard

with query_count_guard(warn_above=20, label="GET /articles"):
    return [article_dict(a) for a in Article.objects.all()]

warn_above cae a settings.QUERY_COUNT_WARN si el caller no lo pasa. None (default) deja el guard inerte (cuenta sin avisar).

Ventana sticky read-after-write (READ_AFTER_WRITE_WINDOW)

Cuando DATABASE_ROUTERS enruta lecturas a una réplica, una request que escribe y vuelve a leer puede ver una fila stale antes de que la réplica replique. El router registra cada escritura por modelo en un ContextVar y fija las lecturas siguientes del mismo modelo al alias primary durante READ_AFTER_WRITE_WINDOW segundos (default 3.0). 0 o None desactiva.

Para queries de analítica que prefieren la réplica incluso justo tras una escritura, pasa sticky=False por los hints del router (reenviados por Manager.get_queryset() cuando llamas a Model.objects.using("replica", sticky=False)).

dorm.contrib.querylog — captura de queries por request

Captura cada sentencia SQL dentro de un bloque QueryLog. Middleware ASGI incluido: envuelve cada request HTTP / WebSocket y expone el log en scope["dorm_querylog"].

from dorm.contrib.querylog import QueryLog, QueryLogASGIMiddleware

with QueryLog() as log:
    do_work()

# log.summary() devuelve list[TemplateStats] ordenado por total_ms desc.
for stats in log.summary():
    print(stats.template, stats.count, stats.p95_ms)
# O serializa como dicts plain JSON-friendly:
# [s.to_dict() for s in log.summary()]

app = QueryLogASGIMiddleware(your_asgi_app)

TemplateStats es un @dataclass(slots=True) con atributos template, count, total_ms, p50_ms, p95_ms. QueryRecord (uno por sentencia ejecutada) carga sql, params, vendor ("postgresql" / "sqlite" / "libsql" — del sender de la señal), elapsed_ms y error. Ambos exponen .to_dict() para pipelines de serialización que prefieran dicts.

Aviso de query lenta (SLOW_QUERY_MS)

Cada sentencia ejecutada se mide independientemente de la configuración — las señales pre_query / post_query ya necesitan el tiempo transcurrido, así que el aviso de query lenta no añade coste extra. Cuando el tiempo supera el umbral configurado, el logger dorm.db.backends.<vendor> emite una línea WARNING con el SQL:

WARNING dorm.db.backends.postgresql: slow query (812.43ms ≥ 500ms): SELECT ...

Configuración (gana la primera fuente que no sea None):

Origen Ejemplo Notas
settings.SLOW_QUERY_MS (3.0+) dorm.configure(SLOW_QUERY_MS=200) siempre gana; recomendado en producción
Variable de entorno DORM_SLOW_QUERY_MS export DORM_SLOW_QUERY_MS=300 fallback cuando no hay setting explícita
Default 500.0 si nada está configurado

Poner SLOW_QUERY_MS=None desactiva el aviso por completo (la comparación misma se omite). Poner 0 hace que toda query se registre como lenta — útil en desarrollo para sacar todas las sentencias SQL a nivel WARNING sin activar el flujo DEBUG entero.

El umbral se memoiza tras configure(...) para que el camino caliente no rehaga el lookup por query. Una llamada posterior a configure(SLOW_QUERY_MS=...) invalida el valor memoizado.

import dorm, logging

# Producción: avisar de cualquier query más lenta que 200 ms.
dorm.configure(SLOW_QUERY_MS=200, DATABASES={...})

# Encadena el aviso a tu handler de alertas.
logging.getLogger("dorm.db").addHandler(your_alert_handler)

Para silenciar por backend: silencia el logger más específico (dorm.db.backends.postgresql) — todo el namespace dorm.db es jerárquico.

Compartir el event loop async

Si ejecutas código async (FastAPI, scripts asyncio), asegúrate de que todos tus tests comparten un event loop:

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"

Sin esto, cada test crea un loop nuevo y un pool nuevo. Los pools viejos quedan colgando como conexiones dangling, max_connections sube, y al final CI empieza a colgarse.

Logging

dorm usa el módulo stdlib logging bajo el namespace dorm. Loggers útiles:

Logger Qué emite
dorm.db.pool INFO en open/close del pool, WARNING en agotamiento
dorm.db.lifecycle.postgresql INFO en open/close del pool PG (tamaño/timeout); el nombre de BD y host se emiten solo a DEBUG, así no se filtran metadatos por-tenant en un sink INFO sin habilitarlo explícitamente
dorm.migrations INFO por migración aplicada
dorm.queries DEBUG por SQL ejecutada (off por defecto)
dorm.db.backends.<vendor> WARNING por query más lenta que SLOW_QUERY_MS (ver Aviso de query lenta)
dorm.signals ERROR por excepción de receiver (con traceback completo) — conéctalo a Sentry / tu pipeline de alertas para que un post_save roto sea observable
dorm.conf INFO cuando un settings.py se autodescubre (auditoría de qué archivo conformó la configuración)
import logging
logging.getLogger("dorm.queries").setLevel(logging.DEBUG)
# Enrutar fallos de señales a tu handler de alertas:
logging.getLogger("dorm.signals").addHandler(tu_handler_alerta)

Notas de seguridad

Algunos puntos a tener en cuenta en despliegues de producción:

  • El auto-discovery de settings.py ejecuta código Python. Si no pasas --settings= ni DORM_SETTINGS=, dorm recorre el cwd y el directorio de sys.argv[0] buscando un settings.py y ejecuta el primero que encuentra (exec_module()). Es el comportamiento diseñado (imita el de manage.py de Django) pero implica que un settings.py que termine en tu directorio de trabajo se ejecutará como código. Pasa explícitamente --settings=miproyecto.settings en los runners de producción para evitar ambigüedad, y revisa tus imágenes de contenedor por archivos settings.py espurios.
  • Los logs DEBUG de queries enmascaran valores ligados a columnas cuyo nombre coincida con password, token, api_key, secret El resto se imprime literal para ayudar al debugging. Si rediriges los logs DEBUG a un sink compartido (Datadog, Loki), asegúrate de que la lista de redacción cubra tus columnas de credenciales específicas del dominio; si no, extiéndela vía la tupla dorm.db.utils._SENSITIVE_COLUMN_PATTERNS, o filtra en el handler del logger. Las señales pre_query / post_query siempre reciben los params crudos; si los reenvías a sinks externos, sanitiza ahí también.
  • Las migraciones son atómicas por archivo. Un fallo en la op N hace rollback de las ops 1..N-1 y la migración no queda registrada como aplicada — así un dorm migrate reintentado vuelve a aplicar limpiamente. La misma garantía cubre el rollback y migrate_to. En SQLite esto requirió forzar un BEGIN explícito (el módulo sqlite3 de Python no auto-inicia transacción antes de DDL); en PostgreSQL todo el DDL pasa por la conexión fijada por el bloque atomic() activo.
  • execute_streaming() se niega a correr dentro de atomic(). Los cursores server-side de PostgreSQL necesitan su propia transacción; el fallback silencioso anterior cargaba todo el resultado en memoria. Si necesitas streaming dentro de una transacción, reestructura: lee las PKs a una lista fuera del bloque y luego itera sobre ellas.

File storage

FileField escribe a través de settings.STORAGES. En producción:

  • No hardcodees credenciales. boto3 lee creds de la cadena estándar — rol IAM en EC2/ECS/Lambda, env vars (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), ~/.aws/credentials. Deja access_key / secret_key sin definir en STORAGES para que el runtime elija las correctas; reserva esas opciones para dev local y MinIO.
  • IAM con mínimo privilegio. El rol necesita s3:PutObject, s3:GetObject, s3:DeleteObject y s3:ListBucket (solo para el check de colisión de get_available_name) sobre el bucket configurado. No hace falta tocar ACLs de bucket-policy si dejas default_acl="private" y dependes de URLs presignadas.
  • TTL de URL presignada. El default querystring_expire=3600 es una hora — suficiente para que el usuario haga clic, suficiente corto para que un enlace exfiltrado caduque antes de ser útil. No lo subas a días.
  • Entrega vía CDN público. Para assets estáticos detrás de CloudFront / Cloudflare, configura custom_domain con el hostname de la CDN. La URL que devuelve S3Storage.url(name) se salta el baile del firmado y apunta directamente a la CDN, que tira del bucket vía Origin Access Identity. Ahorra CPU del firmado por petición y juega bien con el cache del navegador.
  • Lifecycle del bucket. Configura reglas de lifecycle en S3 para mover uploads fríos a Glacier o expirarlos tras N días. dorm no rastrea la antigüedad del upload; expirar es trabajo del bucket.
  • Backups de FileSystemStorage. Si sigues usando disco local en producción (apps mono-máquina, deploys on-prem), respalda el directorio location junto con la BD — la fila en documents y los bytes en /var/app/media/... son el mismo registro lógico partido en dos almacenes. Restáuralos juntos o ninguno.
  • Guardia anti path-traversal. FileSystemStorage._resolve_path rechaza cualquier nombre que escape de location — defensa-en-profundidad contra callables upload_to controlados por el usuario. El backend S3 no tiene filesystem que escapar, pero su get_valid_name también filtra componentes de path.
  • No pongas MEDIA_ROOT dentro del codebase. Configura location en una ruta de un volumen montado aparte para que un redeploy no borre los uploads de los usuarios.

Para el lado FastAPI de los uploads (handling de multipart, descarga en streaming, respuestas con URL presignada), ver FastAPI: Subida de archivos.

Checklist

  • [ ] MAX_POOL_SIZE × workers ≤ max_connections de Postgres / 2
  • [ ] dorm dbcheck corre en CI
  • [ ] dorm migrate --dry-run corre como gate de deploy en prod
  • [ ] --settings= o DORM_SETTINGS= explícitos en los runners de producción
  • [ ] /healthz cableado a la sonda de readiness
  • [ ] pre_query / post_query trazado a tu APM
  • [ ] Tests async con event loop session-scoped
  • [ ] Router de réplica definido si el tráfico supera una caja
  • [ ] STORAGES usa creds del rol IAM / env vars (sin keys hardcodeadas)
  • [ ] MEDIA_ROOT en un volumen persistente (si FileSystemStorage)
  • [ ] TTL de URLs presignadas ajustado a tus requisitos de auditoría / compliance

Configuración por URL/DSN (2.1+)

Las entradas de DATABASES aceptan ahora un string URL o un dict con clave URL. Útil para sacar la cadena de conexión directamente de DATABASE_URL sin escribir el cableado de HOST/PORT/USER/PASSWORD::

import os, dorm

dorm.configure(DATABASES={
    "default": os.environ["DATABASE_URL"],
    # O con overrides:
    # "default": {"URL": os.environ["DATABASE_URL"], "MAX_POOL_SIZE": 30},
})

Los parámetros conocidos del query string (MAX_POOL_SIZE, POOL_TIMEOUT, POOL_CHECK, MAX_IDLE, MAX_LIFETIME, PREPARE_THRESHOLD) se elevan como claves top-level de DATABASES. El resto cae en OPTIONS.

Puerta pre-despliegue: dorm doctor (2.1+)

Ejecuta dorm doctor en CI para fallar builds cuya configuración tropiece con un footgun conocido de producción. Ejemplos que pilla: tamaño de pool pequeño, falta de sslmode en host PG remoto, FKs sin índice, retry de errores transitorios desactivado.

Detección de drift post-deploy: dorm diff (4.0+)

Tras desplegar y aplicar migraciones, ejecuta:

dorm diff --apps myapp.models || exit 1

como step en el pipeline. Compara los modelos del registro contra el schema vivo (information_schema / sqlite_master) y aborta el release si una migración no aplicó (típico tras squashes mal planificados o entornos parcialmente migrados).

Query budget — proteger SLA HTTP (4.0+)

import dorm

@app.get("/heavy")
async def heavy(request):
    async with dorm.abudget(timeout_ms=200, max_rows=10_000):
        return await Order.objects.afilter(status="pending")

PG aplica SET LOCAL statement_timeout dentro de un aatomic() implícito; BudgetExceeded se eleva si pasa el limit de filas. Trade-off: el bloque queda en una transacción — todas las escrituras commit/rollback juntas.

Circuit breaker (4.0+)

from dorm.contrib.circuit_breaker import circuit_breaker, CircuitOpenError

cb = circuit_breaker("default", failure_threshold=5, open_window_s=30.0)

def safe_count() -> int | None:
    try:
        with cb:
            return Author.objects.count()
    except CircuitOpenError:
        return None        # devuelve cache, valor por defecto, etc.

CLOSED → OPEN (tras N fallos consecutivos) → HALF_OPEN (tras open_window_s) → CLOSED o reabrir según el siguiente probe. Per-proceso. Para coordinación cross-worker, monta encima Redis.

Pool task affinity (4.0+)

Reusa una checkout por request en handlers async:

from dorm.contrib.task_pool import pinned_connection

@app.middleware("http")
async def pin_db(request, call_next):
    async with pinned_connection():
        return await call_next(request)

Una conexión PG por request (en lugar de N). Reduce churn 10x en handlers que emiten 5+ queries.

Lag-aware read routing (4.0+)

from dorm.contrib.lag_router import LagAwareReadRouter

DATABASE_ROUTERS = [
    LagAwareReadRouter(
        primary="primary",
        replicas=["replica_1", "replica_2"],
        max_lag_seconds=2.0,
        cache_seconds=5.0,
    ),
]

El router consulta pg_last_xact_replay_timestamp() cada cache_seconds. Lag > umbral → desvío automático al primary.

Migraciones zero-downtime (4.0+)

Para ADD COLUMN NOT NULL sobre tabla grande, usa la receta en Migraciones online:

operations = [
    AddFieldOnline("Order", "currency", dorm.CharField(...)),
    BackfillBatch(table="orders", update_sql=..., batch_size=10_000),
    SetNotNullOnline("Order", "currency"),
]

PG ≥ 12 nunca reescribe la tabla.

OpenTelemetry enriquecido (4.0+)

from dorm.contrib.otel import instrument
instrument(tracer_name="myapp.dorm")

Cada query genera un span con SemConv 1.20+: - db.operation (verb) - db.sql.table / db.collection.name - db.dorm.alias - Span name: "<OPERATION> <table>" — Datadog/Honeycomb agrupa por tabla automáticamente.