Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Signals
signals

Signals

Signals, transaction.on_commit и границы скрытых side effects

Django signals: скрытая связь с явными границами

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 сохраняет совместимость.

#Регистрация в AppConfig

# 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 функция обычно живёт всё время процесса.

#Side effect только после commit

Неверный 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 не обнаруживает переход в published

if 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 invalidation и транзакция

Удаление cache в post_save до commit создаёт окно: другой запрос промахивается, читает прежнее committed значение и снова кладёт его в cache. Инвалидируйте после commit.

Один общий ключ списка может зависеть от category, tenant, locale и visibility. Receiver должен знать схему ключей — это уже тесная связь. Часто явный cache service из use case проще, чем сигнал под видом «развязки».

На rollback callbacks не выполняются. В TestCase внешняя тестовая транзакция обычно не коммитится, поэтому для проверки используют self.captureOnCommitCallbacks(execute=True).

#Создание Profile: допустимый, но не обязательный signal

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.

#Какие операции не отправляют save signals

bulk_create(), bulk_update() и QuerySet.update() не вызывают save(), pre_save и post_save. Это не дублирование signals, а их полное отсутствие. transaction.on_commit() сам по себе не восстанавливает пропущенные события.

Варианты:

  • общий явный bulk use case выполняет инварианты и один aggregate side effect;
  • поштучный save() используется только когда его стоимость приемлема;
  • database constraint/trigger защищает правило независимо от пути записи.

QuerySet.delete() отправляет delete signals для удаляемых объектов, что может загрузить их в память и сделать массовое удаление дорогим. Cascade также создаёт события — receiver должен учитывать origin и повторяемость.

#Custom signals

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 остаётся внутрипроцессным.

#Аудит требует actor и прежнего состояния

Глобальный post_save receiver не знает надёжно:

  • кто инициировал изменение;
  • из какого use case оно пришло;
  • прежнее значение при конкурентной записи;
  • почему было разрешено действие;
  • что произошло через bulk/raw SQL.

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.

#Тестирование receivers

Полезны два уровня:

  1. unit test receiver как обычной функции с явными аргументами;
  2. integration test, что реальное 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, но помните о глобальном эффекте внутри процесса.

#Когда signal оправдан

Хороший кандидат:

  • необязательная интеграция независимого installed app;
  • реакция на событие framework/third-party кода, где нет явной точки вызова;
  • лёгкое, идемпотентное уведомление, корректно привязанное к commit.

Плохой кандидат:

  • денежный перевод, публикация, изменение тарифа;
  • валидация и permission;
  • длинная цепочка взаимных save;
  • внешняя доставка с требованием «ровно один раз»;
  • логика, которую bulk path обязан повторить.

Если receiver критичен для корректности основной операции, это сильный сигнал сделать вызов явным.

#Legacy и удалённый API

Signal(providing_args=...) удалён в Django 4.0. Model signals остаются актуальными, но их семантика расширялась: delete signals передают origin, а async dispatch доступен в современных версиях.

Thread-local user и глобальный audit receiver — характерные legacy-решения. Не заменяйте их механически на ContextVar: сначала определите, нужен ли signal вообще и кто является actor вне HTTP-request.

#Документация

  • Signals
  • Built-in signals
  • Transactions and on_commit

Далее: Тестирование: Unit Tests