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¶
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):
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:
- Construye el nuevo código (artifact inmutable).
dorm migrate --dry-runcontra producción — revisa el SQL.dorm migrate(los advisory locks hacen segura la ejecución concurrente).- 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.
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:
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.pyejecuta código Python. Si no pasas--settings=niDORM_SETTINGS=, dorm recorre elcwdy el directorio desys.argv[0]buscando unsettings.pyy ejecuta el primero que encuentra (exec_module()). Es el comportamiento diseñado (imita el demanage.pyde Django) pero implica que unsettings.pyque termine en tu directorio de trabajo se ejecutará como código. Pasa explícitamente--settings=miproyecto.settingsen los runners de producción para evitar ambigüedad, y revisa tus imágenes de contenedor por archivossettings.pyespurios. - 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 tupladorm.db.utils._SENSITIVE_COLUMN_PATTERNS, o filtra en el handler del logger. Las señalespre_query/post_querysiempre 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 migratereintentado vuelve a aplicar limpiamente. La misma garantía cubre el rollback ymigrate_to. En SQLite esto requirió forzar unBEGINexplícito (el módulosqlite3de Python no auto-inicia transacción antes de DDL); en PostgreSQL todo el DDL pasa por la conexión fijada por el bloqueatomic()activo. execute_streaming()se niega a correr dentro deatomic(). 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. Dejaaccess_key/secret_keysin definir enSTORAGESpara 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:DeleteObjectys3:ListBucket(solo para el check de colisión deget_available_name) sobre el bucket configurado. No hace falta tocar ACLs de bucket-policy si dejasdefault_acl="private"y dependes de URLs presignadas. - TTL de URL presignada. El default
querystring_expire=3600es 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_domaincon el hostname de la CDN. La URL que devuelveS3Storage.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 directoriolocationjunto con la BD — la fila endocumentsy 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_pathrechaza cualquier nombre que escape delocation— defensa-en-profundidad contra callablesupload_tocontrolados por el usuario. El backend S3 no tiene filesystem que escapar, pero suget_valid_nametambién filtra componentes de path. - No pongas
MEDIA_ROOTdentro del codebase. Configuralocationen 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 dbcheckcorre en CI - [ ]
dorm migrate --dry-runcorre como gate de deploy en prod - [ ]
--settings=oDORM_SETTINGS=explícitos en los runners de producción - [ ]
/healthzcableado a la sonda de readiness - [ ]
pre_query/post_querytrazado a tu APM - [ ] Tests async con event loop session-scoped
- [ ] Router de réplica definido si el tráfico supera una caja
- [ ]
STORAGESusa creds del rol IAM / env vars (sin keys hardcodeadas) - [ ]
MEDIA_ROOTen 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:
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+)¶
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.