18 novembro 2022

Integridade de dados e race condition com sinais (signals) no Django

Para quem já trabalhou com Django, sabe dos ótimos recursos disponíveis dentro do framework, além do ilustríssimo e ótimo ORM, uma estrutura de rotas, comunidade enorme e uma vasta experiência para fornecer ferramentas de qualidade para resolução de problemas no desenvolvimento de aplicações web. Mas além desses recursos ele conta também com um o sinais ou signals um disparador de eventos que acontece antes ou depois que uma ação é feita.

Uma das formas mais comuns de usar sinais no Django é quando precisamos executar uma determinada tarefa depois de salvar as informações no banco de dados, claro, partindo de um modelo (model) qualquer. O código seria assim:

from django.db.models.signals import pre_save
from django.core.mail import send_mail
from django.dispatch import receiver

from bank.models import Account

@receiver(post_save, sender=Account)
def send_mail(sender, instance, **kwargs):
    if instance.balance < 0:
        send_mail(
            subject="Saldo baixo!",
            message="Você entrou no cheque especial",
            from_email="admin@domain.tld",
            recipient_list=["user@domain.tld"]
        )

Essa seria uma possível implementação, porém falha, dado que parte da instrução da função send_email depende dos dados do modelo, como é visto logo abaixo da definição dela. O problema é que logo após o sinal ser acionado, o banco de dados ainda está processando o salvamento das informações, por tanto pode ser que no momento da consulta do instance.balance o valor seja o anterior e não 0, como seria esperado, isso é conhecido também como race condition.

A resolução do problema é simples e utilizando os próprios recursos do Django. Dado que as duas atividades estão perdendo a comunicação ao longo de suas execuções e uma depende da outra. É necessário esperar uma ser concluída para em seguida acionar a outra. Para isso é necessário garantir que os dados foram salvos para na sequência fazer o disparo da tarefa (send_mail). Para isso podemos usar o modulo de transaction e a função on_commit que é a responsável por garatir que o sinal será chamado ao fim da "transação" de dados no banco (ou seja depois que ele salvar os dados).

O uso do on_commit para essa finalidade teria uma implementação assim:

[...]

from django.db import transaction

def do_mail(instance):
    if instance.balance < 0:
        send_mail(
        subject="Saldo baixo!",
        message="Você entrou no cheque especial",
        from_email="admin@domain.tld",
        recipient_list=["user@domain.tld"]
    )

@receiver(post_save, sender=Account)
def send_mail(sender, instance, **kwargs):
    transaction.on_commit(lambda: do_email(instance))

Os sinais do Django é uma ferramenta muito útil em muitos casos, mesmo tendo possibilidade de adicionar ferramentas externas para atividades de baixa escala o uso desse recurso se faz necessário, porém é importante entender alguns detalhes para extrair o máximo possível de funcionalidade durante o desenvolvimento de aplicações web.