Signals, transaction.on_commit и границы скрытых side effects
Signal синхронно уведомляет receivers внутри процесса приложения. Он полезен, когда отправитель не должен импортировать необязательный получатель, но не является очередью, durable event bus или заменой транзакционного use case.
Материал актуален для Django 5.2 LTS и 6.0.
У сигнала есть sender, receivers и keyword arguments. Частые model signals:
| Signal | Момент |
|---|---|
pre_save | перед Model.save() |
post_save | после SQL сохранения, но возможно до commit транзакции |
pre_delete | перед удалением |
post_delete | после удаления |
m2m_changed | до/после add, remove и clear связи |
post_save не означает «данные уже гарантированно зафиксированы». Если внешний side effect должен видеть committed state, нужен transaction.on_commit().
Model receiver принимает служебные аргументы: using, raw, update_fields; delete signals также получают origin в актуальном Django. Подпись с **kwargs сохраняет совместимость.
# blog/apps.py
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = "blog"
def ready(self):
from . import signals # noqa: F401# blog/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Post
@receiver(
post_save,
sender=Post,
dispatch_uid="blog.post.invalidate.v1",
)
def post_saved(sender, instance, created, raw, using, **kwargs):
if raw:
return
...ready() может выполняться больше одного раза в некоторых тестовых сценариях. Обычный module import и стабильный receiver часто уже защищают от дубля, а dispatch_uid делает идентичность подключения явной. Не выполняйте запросы к базе в ready().
Django по умолчанию хранит receivers как weak references. Для локальной/динамически созданной функции, которая иначе может быть собрана GC, используйте connect(..., weak=False); module-level функция обычно живёт всё время процесса.
Неверный receiver отправляет письмо прямо из post_save: последующая ошибка откатит строку, а письмо уже уйдёт. Безопаснее запланировать короткий callback:
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Post
from .tasks import refresh_public_post_cache
@receiver(
post_save,
sender=Post,
dispatch_uid="blog.post.cache.v1",
)
def schedule_cache_refresh(sender, instance, raw, using, **kwargs):
if raw:
return
post_id = instance.pk
transaction.on_commit(
lambda: refresh_public_post_cache(post_id),
using=using,
)Callback выполняется после commit, но всё ещё в процессе приложения и без durable retry. Для гарантированной публикации события используйте transactional outbox: запись domain event сохраняется в той же транзакции, отдельный worker доставляет её идемпотентно.
Замыкайте скалярный post_id, а не изменяемый model instance. Если за время до commit объект в Python изменился, callback иначе увидит неожиданное состояние.
created=True не обнаруживает переход в publishedif created and instance.status == Post.Status.PUBLISHED:
...Этот код реагирует только на создание уже опубликованной строки. Он пропустит обычный переход draft → published. Сравнение старого значения в pre_save тоже ненадёжно при конкуренции, QuerySet.update() и bulk paths.
Публикация — явный use case:
from django.db import transaction
@transaction.atomic
def publish_post(*, post_id, actor):
post = Post.objects.select_for_update().get(pk=post_id)
post.ensure_can_publish(actor=actor)
post.status = Post.Status.PUBLISHED
post.save(update_fields=["status", "published_at"])
transaction.on_commit(
lambda: post_published.delay(post.pk),
)
return postЗдесь видны actor, блокировка, инвариант и момент side effect. Signal для этого же события добавил бы второй скрытый путь.
Удаление cache в post_save до commit создаёт окно: другой запрос промахивается, читает прежнее committed значение и снова кладёт его в cache. Инвалидируйте после commit.
Один общий ключ списка может зависеть от category, tenant, locale и visibility. Receiver должен знать схему ключей — это уже тесная связь. Часто явный cache service из use case проще, чем сигнал под видом «развязки».
На rollback callbacks не выполняются. В TestCase внешняя тестовая транзакция обычно не коммитится, поэтому для проверки используют self.captureOnCommitCallbacks(execute=True).
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Profile
User = get_user_model()
@receiver(post_save, sender=User, dispatch_uid="accounts.profile.create.v1")
def create_profile(sender, instance, created, raw, **kwargs):
if created and not raw:
Profile.objects.get_or_create(user=instance)Не нужен второй receiver с instance.profile.save(): профиль может отсутствовать, лишнее сохранение происходит при каждом сохранении user, а recursion/ошибки становятся неожиданными.
Если Profile обязателен для регистрации, явный registration service с одной транзакцией лучше. Signal полезен, когда user создают разные независимые apps и профиль является необязательным extension.
m2m_changed: учитывайте сторону и фазуReceiver подключается к through model:
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
@receiver(m2m_changed, sender=Post.tags.through)
def post_tags_changed(
sender,
instance,
action,
reverse,
model,
pk_set,
using,
**kwargs,
):
if action in {"post_add", "post_remove", "post_clear"}:
schedule_tag_index_refresh(instance=instance, reverse=reverse)reverse меняет смысл instance и model: вызов мог начаться как post.tags.add(tag) или tag.posts.add(post). pk_set — set primary keys для add/remove, но при clear равен None.
Если нужны удаляемые ids при clear, снимите их в pre_clear и сохраните только на время операции, затем обработайте post_clear. Однако ручной denormalized counter по add/remove/clear хрупок при concurrency, прямой работе с through model и восстановлении данных. Сначала рассмотрите Count()/материализованный отчёт/DB trigger.
bulk_create(), bulk_update() и QuerySet.update() не вызывают save(), pre_save и post_save. Это не дублирование signals, а их полное отсутствие. transaction.on_commit() сам по себе не восстанавливает пропущенные события.
Варианты:
save() используется только когда его стоимость приемлема;QuerySet.delete() отправляет delete signals для удаляемых объектов, что может загрузить их в память и сделать массовое удаление дорогим. Cascade также создаёт события — receiver должен учитывать origin и повторяемость.
from django.dispatch import Signal
report_exported = Signal()responses = report_exported.send(
sender=export_report,
report_id=report.pk,
storage_key=storage_key,
)Receiver обязан принимать sender и **kwargs. send() прерывается на исключении receiver; send_robust() возвращает exception рядом с receiver и продолжает остальных. «Robust» не означает retry или delivery guarantee.
Аргумент Signal(providing_args=...) удалён в Django 4.0. Он и раньше был документационным, а не runtime validation. Актуальный конструктор — Signal().
В современных Django signals поддерживают async receivers и asend()/asend_robust(), но sync и async receivers группируются и адаптируются, поэтому нельзя строить бизнес-корректность на точном порядке исполнения. Signal остаётся внутрипроцессным.
Глобальный post_save receiver не знает надёжно:
Thread-local «current user» течёт между запросами при ошибке очистки и не моделирует async context, команды, tasks и system actor. ContextVar технически корректнее для async isolation, но всё равно скрывает зависимость.
Для security audit передавайте actor явно в use case и создавайте append-only AuditEntry в той же транзакции. Для охвата raw SQL/нескольких сервисов рассмотрите database audit/CDC. Не подключайте глобальный receiver ко всем моделям: он может начать аудировать собственный AuditEntry и уйти в recursion.
object_id лучше хранить как строку или пару content type + text id, если модели могут иметь UUID. Sensitive old/new values редактируют и защищают отдельными permissions/retention.
Полезны два уровня:
save() вызывает ожидаемое поведение и on-commit callback.Не отключайте глобальный signal в setUp() и не надейтесь вернуть его в tearDown(): падение setup/test или параллельный запуск может оставить process в изменённом состоянии. Лучше patch внешнего collaborator:
from unittest.mock import patch
def test_post_save_schedules_refresh(self):
with patch("blog.signals.refresh_public_post_cache") as refresh:
with self.captureOnCommitCallbacks(execute=True):
post = Post.objects.create(title="Test")
refresh.assert_called_once_with(post.pk)Если disconnect неизбежен для legacy-теста, используйте точные sender/dispatch_uid и try/finally, но помните о глобальном эффекте внутри процесса.
Хороший кандидат:
Плохой кандидат:
Если receiver критичен для корректности основной операции, это сильный сигнал сделать вызов явным.
Signal(providing_args=...) удалён в Django 4.0. Model signals остаются актуальными, но их семантика расширялась: delete signals передают origin, а async dispatch доступен в современных версиях.
Thread-local user и глобальный audit receiver — характерные legacy-решения. Не заменяйте их механически на ContextVar: сначала определите, нужен ли signal вообще и кто является actor вне HTTP-request.
Далее: Тестирование: Unit Tests