Señales¶
Las señales te permiten engancharte a eventos del ciclo de vida del
modelo (save, delete) y a cada query SQL sin acoplar esa lógica
al código del modelo. dorm trae seis señales integradas; la API
replica la de Django.
Cuándo usar una señal (y cuándo no)¶
Usa una señal cuando:
- el hook es transversal — auditoría, invalidación de caché, indexado de búsqueda, tracing, métricas — y no quieres añadir una llamada de método en cada sitio que guarda;
- código de terceros necesita reaccionar a tus modelos sin modificarlos.
No uses una señal cuando:
- la lógica pertenece al modelo — sobrescribe
save()/clean(). Las señales son acopladas-flojo a propósito y eso hace que el flujo de control sea más difícil de seguir; - necesitas garantizar un valor de retorno o abortar la operación —
las excepciones de
pre_savese tragan (mira Pitfalls).
Las seis señales integradas¶
Todas viven en dorm.signals. Disparan idénticamente para
operaciones sync y async: los handlers son siempre callables
síncronos.
| Señal | Dispara | El sender es |
Kwargs extra |
|---|---|---|---|
pre_save |
antes de que save() / asave() ejecute SQL |
la clase modelo | instance, raw=False, using, update_fields |
post_save |
tras volver el INSERT/UPDATE | clase modelo | instance, created (bool), raw=False, using, update_fields |
pre_delete |
antes de que delete() / adelete() ejecute SQL |
clase modelo | instance, using |
post_delete |
tras volver el DELETE | clase modelo | instance, using |
pre_query |
antes de cada sentencia SQL | el string del vendor ("postgresql" / "sqlite") |
sql, params |
post_query |
tras completar el SQL (o lanzar) | string del vendor | sql, params, elapsed_ms, error |
Algunas notas sobre los kwargs:
instancees la instancia viva del modelo, no una copia — mutarla enpre_savesí se ve en el SQL siguiente. Es el patrón típico para "auto-fija un slug si falta".createdenpost_saveesTruesi la fila se acaba de insertar,Falsepara updates. Es la forma más limpia de distinguir los dos sin re-consultar.raw=Falseestá reservado para futuro soporte de carga de fixtures; ahora siempre esFalse. Coincide con la firma de Django para que los handlers escritos para Django porten directos.usinges el alias de BD que tocó la operación ("default","replica"...) — útil para handlers conscientes del routing.errorenpost_queryes la excepción lanzada (oNonesi la sentencia tuvo éxito). Comprueba siempreerrorantes de tratarelapsed_mscomo tiempo de query exitosa.
Firma del receiver¶
Siempre dos partes: sender posicional, después **kwargs. Puedes
desempaquetar los kwargs que te interesen e ignorar el resto con
**_.
def my_handler(sender, **kwargs):
instance = kwargs["instance"]
created = kwargs.get("created", False)
...
La razón del catch-all **kwargs: dorm puede añadir nuevos
keyword args a una señal en el futuro (mira update_fields, que se
añadió sin romper receivers anteriores). Un handler que liste cada
argumento explícitamente empezará a lanzar TypeError el día que
aparezca uno nuevo. Acaba siempre la firma con **kwargs (o
**_ si ignoras todo lo que no sea sender).
Conectar y desconectar¶
from dorm.signals import post_save
def audit(sender, instance, created, **kw):
AuditLog.objects.create(
model=sender.__name__,
pk=instance.pk,
action="created" if created else "updated",
)
post_save.connect(audit, sender=Article)
Signal.connect(receiver, sender=None, weak=True, dispatch_uid=None):
| Argumento | Efecto |
|---|---|
receiver |
el callable; firma def fn(sender, **kwargs) |
sender |
solo invoca cuando send() se llamó con este sender. Patrón típico: sender=Article para que el handler solo dispare en saves de Article, no en cualquier modelo |
weak |
default True. dorm guarda una WeakRef al receiver, así un handler que es método de instancia desaparece automáticamente cuando su objeto dueño se garbage-collecta. Pasa False para funciones a nivel de módulo que quieras tener "vivas para siempre" (y para silenciar el warning de WeakMethod si tu handler es un método ligado cuyo dueño no puedes mantener vivo de otra forma) |
dispatch_uid |
una identidad string estable. Conectar otra vez con el mismo dispatch_uid reemplaza la registración previa. Úsalo para llamadas a connect() en top-level de módulo así una re-importación no registra dos veces |
Desconecta con cualquiera de:
post_save.disconnect(audit) # por receiver
post_save.disconnect(sender=Article) # todos los handlers de este sender
post_save.disconnect(dispatch_uid="audit-x") # por uid
Patrón decorador @receiver¶
dorm no trae un decorador @receiver (el de Django no añade nada
de comportamiento — solo llama a signal.connect). Puedes hacerlo
en dos líneas:
def receiver(signal, **kwargs):
def deco(fn):
signal.connect(fn, **kwargs)
return fn
return deco
@receiver(post_save, sender=Article, dispatch_uid="reindex-articles")
def reindex(sender, instance, **kw):
search.index(instance)
Observabilidad con pre_query / post_query¶
Estas dos disparan en cada sentencia SQL — sync o async — así que son el punto de integración para OpenTelemetry, Datadog, structlog o cualquier cosa que necesite métricas por query.
from dorm.signals import post_query
def trace(sender, sql, params, elapsed_ms, error, **kw):
log.info(
"query",
vendor=sender, # "postgresql" / "sqlite"
ms=elapsed_ms,
ok=error is None,
sql=sql,
)
post_query.connect(trace, weak=False, dispatch_uid="apm-trace")
Algunas reglas duras:
- Mantén los handlers baratos. Corren inline en el camino de la
query. Un handler lento en
post_queryralentiza cada llamada a BD. Si necesitas publicar métricas por la red, mete el trabajo en una cola (asyncio.Queue, ThreadPoolExecutor) y vuelve. - No emitas más queries desde dentro de una señal de query. Es un bucle infinito. Si necesitas guardar muestras, añádelas a un ring buffer en memoria y persiste fuera de banda.
erroresNoneen éxito. Los handlers que siempre leenelapsed_mspara tiempo deberían comprobarerror is not Noneantes de clasificar la llamada como "query lenta" — las queries fallidas suelen verse rápidas porque cortan el camino.
Receivers asíncronos¶
Los receivers pueden ser funciones async def. Se conectan igual
que un handler normal — dorm detecta corrutinas con
inspect.iscoroutinefunction en el momento del dispatch:
import asyncio
from dorm.signals import post_save
async def index_in_search(sender, instance, created, **kw):
await search_client.upsert(instance)
post_save.connect(index_in_search, sender=Article, weak=False)
El despacho se divide en dos caminos:
Model.asave()/Model.adelete()llaman aSignal.asend()por dentro. Los receivers síncronos se invocan directamente; los asíncronos se awaitan secuencialmente, en el orden en que se conectaron. Esto coincide con Django y mantiene predecibles los handlers que comparten estado. Si quieres concurrencia, abre unasyncio.gatherdentro de un receiver.Model.save()/Model.delete()se quedan en la ruta síncrona. Un receiver async ahí no tiene loop donde correr, así que dorm registra unWARNINGendorm.signalsy lo salta — sin perder trabajo en silencio ni hacer deadlock enasyncio.run.
# Dispara desde asave / adelete:
async def audit(sender, instance, **kw):
await audit_log.append(instance.pk, "saved")
post_save.connect(audit, sender=Order, weak=False)
await Order(...).asave() # audit() corre
Order(...).save() # audit() se salta + warning
También puedes invocar Signal.asend() directamente para señales
propias:
from dorm.signals import Signal
deployed = Signal()
async def notify_slack(sender, **kw):
await slack.post(f"deployed {sender}")
deployed.connect(notify_slack, weak=False)
await deployed.asend(sender="prod-v2.1")
asend() devuelve la misma forma [(receiver, valor), …] que
send(). Una corrutina devuelta por un receiver síncrono se
awaitéa de forma transparente, así que helpers que envuelvan no
dejan trabajo pendiente.
pre_query / post_query siguen siendo síncronas¶
Se despachan desde el context manager de log SQL, compartido entre
backends sync y async. Los receivers async conectados ahí se saltan
con un warning — instrumenta el tracing async vía
post_save / post_delete (o agenda una task desde un receiver
síncrono ligero).
Efectos colaterales internos¶
dorm en sí no se suscribe a sus propias señales — existen únicamente para código de usuario. Eso significa:
- Desactivar una señal (p.ej.
disconnect-eando todos los receivers) nunca rompe operaciones del ORM. - Un handler que lance no bloquea un save / delete / query — la
excepción se loggea a
ERRORen el loggerdorm.signals, pero el código que llama continúa (mira más abajo).
Manejo de fallos en receivers¶
Por defecto, una excepción lanzada por un receiver se loggea vía el
logger dorm.signals a nivel ERROR (con traceback completo) y
luego se suprime, así un único handler roto no puede tumbar un
camino de save/delete. Para conectarlo a tu stack de observabilidad:
import logging
# Manda los fallos de señales de dorm a Sentry / DataDog / tu handler
logging.getLogger("dorm.signals").addHandler(tu_handler_alerta)
Si prefieres que la excepción propague — útil en tests, o para
señales personalizadas donde un handler fallido debería fallar la
operación — construye la señal con raise_exceptions=True:
from dorm.signals import Signal
evento_estricto = Signal(raise_exceptions=True)
evento_estricto.connect(handler)
evento_estricto.send(sender=obj) # cualquier error en un handler se relanza
Las señales internas (pre_save, post_save, pre_delete,
post_delete, pre_query, post_query) mantienen el
comportamiento legacy "loggear y suprimir" para preservar
compatibilidad.
Pitfalls¶
- Las excepciones de los handlers se loggean, no se tragan en
silencio. Un listener
post_savecon bug ya no desaparece en el vacío; queda registrado en el loggerdorm.signalspara que puedas enrutarlo a Sentry / tu alerta. Si quieres propagación estricta, usa unaSignal(raise_exceptions=True)(ver arriba). pre_saveno puede abortar el save. Lanzar dentro depre_savese loggea pero el INSERT/UPDATE igual corre. Si necesitas vetar una operación, hazlo enModel.clean()(lo invocafull_clean()) o antes de llamar asave().- Recursión. Un handler
post_saveque llame ainstance.save()re-disparapre_save/post_savey puede buclear infinitamente. Usaupdate_fieldspara limitar el nuevo save (salta el re-fire para campos fuera de la lista si tienes cuidado), o protege con un flag thread-local. - La identidad del sender importa. El filtro de
pre_saveusa comparaciónis:connect(handler, sender=Article)solo coincide con saves deArticle, no con subclases deArticle. Si tienes mixins abstractos (TimestampedModel), conecta a cada subclase concreta. - Re-importes de módulo doblan los handlers weak. Si tu
connect()vive en top-level de módulo y el módulo se recarga (Jupyter, hot-reload de dev), el handler queda registrado dos veces. Usadispatch_uidpara hacerlo idempotente.
Referencia¶
API completa + kwargs por señal en la Referencia API.