"""Receivers emitting terminal ``TaskExecution`` signals. ``task_completed``/``task_failed`` fire exactly once, on the transition into a terminal status, so the sync/plain-callable path and any task that updates its own ``TaskExecution`` row are both covered without duplicate emissions. Note: for the ``apply_async`` branch the execution row currently stays ``RUNNING`` (no Celery callback), so completion is only observable when the task updates its own ``TaskExecution`` or runs synchronously. Tracking async completion is a separate enhancement (``PLAN.md`` ยง2.8). """ from __future__ import annotations from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from .models import TaskExecution from .signals import task_completed, task_failed @receiver(pre_save, sender=TaskExecution) def _capture_previous_status(sender, instance, raw=False, **kwargs): if raw or instance.pk is None: return previous = TaskExecution.all_objects.filter(pk=instance.pk).values_list("status", flat=True).first() if previous is not None: instance._previous_status = previous @receiver(post_save, sender=TaskExecution) def _emit_terminal_status(sender, instance, created, raw=False, **kwargs): if raw or created: return previous = getattr(instance, "_previous_status", None) if previous is None or previous == instance.status: return tenant_id = str(instance.tenant_id) if instance.tenant_id else None task_name = getattr(instance.task, "name", "") if instance.status == TaskExecution.Status.SUCCESS: task_completed.send( sender=TaskExecution, tenant_id=tenant_id, task_name=task_name, task_id=instance.pk, duration_ms=_duration_ms(instance), result=instance.result, ) elif instance.status == TaskExecution.Status.FAILURE: task_failed.send( sender=TaskExecution, tenant_id=tenant_id, task_name=task_name, task_id=instance.pk, error=instance.error_traceback, traceback="", ) def _duration_ms(execution: TaskExecution) -> int | None: if execution.started_at is None or execution.completed_at is None: return None return int((execution.completed_at - execution.started_at).total_seconds() * 1000)