To read the english version of this post, visit https://medium.com/@jplethier/what-not-to-do-when-using-async-background-jobs-based-on-rails-sidekiq-experience-b66316fc3874
É muito comum termos tarefas assíncronas em aplicações web. Elas podem ser bem úteis para removermos tarefas lentas e complexas do servidor web, assim como tarefas que dependem de serviços externos que não temos como garantir que estarão sempre online e tarefas que não são necessárias rodarem durante o request http. Dessa forma podemos deixar que os requests http da aplicação web façam somente o que é estritamente necessário e sejam mais rápidos, melhorando a interação do usuário com a aplicação.
Ao mesmo tempo, é fácil nos depararmos com problemas não esperados ao lidarmos com tarefas em background e assíncronas se não tivermos experiência e não soubermos o que evitar para não gerar problemas. Por isso quero compartilhar nesse post práticas que eu evito ao trabalhar com tarefas em background, baseadas na minha experiência pessoal trabalhando com aplicações ruby on rails e usando sidekiq para lidar com as tarefas em background.
Receber objetos complexos
Vamos dizer que nós temos uma tarefa em background que precisa receber uma data e hora(timestamp) como um parâmetro. Considerando o uso do sidekiq nesse exemplo, se simplesmente passarmos um objeto DateTime para a task, o sidekiq terá que transformar o objeto DateTime em json, serializando ele, e depois transformar em DateTime de volta para ser usado na task. Esse processo de transformação normalmente pode ocorrer em diferentes servidores, diferentes máquinas, e não tem como garantir que não haverá perda de precisão. Talvez a perda de precisão seja somente nos milisegundos, e isso não faça diferença para a execução da tarefa, porém isso pode acontecer com outros objetos passados como parâmetro também. O ideal é passarmos objetos de tipos mais simples, fazendo nós mesmos a conversão desses objetos complexos para os tipos mais simples, e transformando de volta dentro da tarefa quando necessário.
Receber registros inteiros do banco de dados
Esse é um caso muito comum em aplicações rails quando configuramos o mailer para ser executado em segundo plano com o sidekiq. Isso ocorre normalmente porque quando o mailer é executado de forma síncrona, passamos o registro inteiro para ele e funciona perfeitamente, e não lembramos de mudar a forma que os parâmetros são recebidos pelo mailer quando mudamos para serem executados em segundo plano, em uma background task. Pode acontecer também se nos acostumamos a trabalhar em um projeto que os envios de emails são síncronos e começamos a trabalhar em outro projeto que todos os envios de email são assíncronos. Seja nos mailers ou seja em outras background tasks, temos dois problemas quando passamos objetos inteiros de registro do banco de dados.
O primeiro problema é similar ao do tópico anterior. Registros do banco de dados são objetos complexos, que possuem múltiplos atributos, de diferentes tipos. Se não nos preocuparmos com o processo de transformação deles, não temos como garantir que não haverá nenhuma perda de precisão dos valores de cada atributo.
O segundo, e mais crítico em minha opinião, é que quando chamamos um job, ou seja, jogamos parte do código para ser executada de forma assíncrona e em segundo plano, não temos como garantir exatamente em que momento esse execução ocorrerá. É possível que quando a tarefa seja executada o registro do banco de dados já tenha sofrido alterações, pode inclusive ter sido removido. Por exemplo, vamos dizer que após o usuário alterar os dados do perfil dele, enfileiramos uma tarefa para ser executada de forma assíncrona para enviar um email informando ele das alterações e passamos o objeto inteiro do usuário como parâmetro, contendo o email como um dos atributos. Porém, antes da tarefa ser executada, o email desse usuário é alterado no banco de dados, fazendo com que quando a tarefa seja executada ela já esteja com um email desatualizado, e a aplicação não envie a comunicação para o email mais novo e correto.
É importante sempre garantirmos que estamos com os dados atualizados ao executarmos um job. Considerando essa necessidade e o risco de perda de precisão que temos ao usar um objeto complexo como parâmetro, o ideal é sempre passarmos como parâmetro para a tarefa somente o necessário para que ela própria consiga buscar o restante das informações no banco de dados(ou em qualquer outro lugar).
Fazer uma chamada para um job dentro de uma transaction do banco de dados(ou qualquer outro processo que possa sofrer um rollback)
Evitar essa prática parece algo óbvio quando pensamos sobre ela. Mas, especialmente com o uso de callbacks no activerecord, já vi esse problema acontecer muitas vezes. Basta chamar um job a partir de um callback que é executado antes da transaction do banco de dados ser finalizada. A melhor solução nesse cenário é usar sempre callbacks que rodam após o commit da transaction do banco de dados quando queremos fazer chamadas para jobs, garantindo assim que o job nunca é chamado antes da transaction ser finalizada.
Outro cenário que já vi acontecer é quando temos use cases ou services encadeados, ou seja, use cases(ou services) que chamam e dependem de outros use cases(ou services). Para ficar mais claro, vamos usar as classes abaixo como exemplo:
Se olharmos para a classe ProfileUpdate, tudo parece estar correto, com o mailer sendo chamado de forma assíncrona fora da transaction do banco de dados. Porém, se analisarmos o fluxo completo, incluindo a execução que acontece dentro da classe UserSafetyDataValidation, vemos que tem um job sendo chamado dentro do método call da classe UserSafetyDataValidation e consequentemente sendo chamado dentro da transaction do banco de dados. Isso pode causar um cenário onde a transaction do banco de dados é revertida se a linha user.update! falhar, ocorrendo um rollback, mas o job é executado mesmo assim, dado que ele é chamado antes da finalização da transaction.
Nessa situação, uma abordagem que podemos usar é encadear as mensagens e eventos de volta nos retornos dos use cases e/ou services, centralizando a chamada a jobs na camada mais externa dos fluxos. Uma possibilidade é usarmos uma estrutura de listeners e broadcast para emitir eventos após a finalização da transaction.
A gem isolator pode ajudar a prevenir que esses cenários ocorram no projeto, e recomendo o post Rails after commit, do blog da evil martians, como uma boa leitura para aprofundar sobre como tratar e evitar esses cenários.
Mudar a assinatura de um job que já está em produção
Esse não é um cenário tão comum de ocorrer como os listados anterior, mas também não é tão raro, não é um edge case. O problema que ocorre quando mudamos a assinatura de um job é que podemos ter tasks desse job enfileiradas já para serem executadas de forma assíncrona que não consideraram essa nova assinatura. Por exemplo, se adicionarmos um novo parâmetro obrigatório a um job, todos as tasks desse job que já estiverem sido enfileiradas antes dessa mudança irão quebrar quando forem puxadas da fila para serem executadas, dado que elas não possuem esse novo parâmetro definido.
Para evitar esse cenário, devemos sempre pensar em como garantir a [retrocompatibilidade](https://en.wikipedia.org/wiki/Backward_compatibility#:~:text=Backward%20compatibility%20(sometimes%20known%20as,especially%20in%20telecommunications%20and%20computing.) dos jobs. Por exemplo, em uma situação onde de fato faça sentido ou seja necessário mudar a assinatura de um job, podemos fazer essa mudança em etapas. Considerando o cenário onde um novo parâmetro obrigatório precisa ser adicionado, podemos no primeiro momento adicionar esse parâmetro como opcional, definindo um valor padrão para ele, e somente em um segundo momento, quando temos segurança e podemos garantir que todas as tasks enfileiradas desse job já possuem a nova assinatura e o novo parâmetro, podemos remover o valor padrão e torná-lo obrigatório. A mesma idéia pode ser usada caso a necessidade seja a mudança do nome, onde podemos criar um novo job com o novo nome primeiro, e só depois removemos o job antigo, quando todas as tarefas que tinham sido enfileiradas considerando o nome antigo já tenham sido executadas.
Não definir filas e suas prioridades
Isso é muito comum no início dos projetos, onde normalmente temos um ambiente mais acelerado e com foco em terminar as funcionalidades essenciais para o lançamento da primeira versão do produto, e normalmente damos prioridade menor para "arrumação da casa" do projeto. Porém, assim que possível, é essencial definir as filas e prioridades que elas terão na execução assíncrona, sendo bem importante definir ao menos duas diferentes prioridades: uma para tarefas que podem ser mais demoradas, onde o tempo de demora e atraso não é muito relevante; e outra para tarefas que não podem demorar muito para rodar, onde o tempo de atraso é relevante e importante para a experiência do usuário. Por exemplo, vamos considerar que temos um job que envia um SMS de confirmação com um código para o usuário após ele mudar o telefone dele, porém quando a tarefa desse job é colocada na fila a mesma se encontra lotada de tarefas de envio de emails promocionais que fazem com que o SMS de confirmação demore muito para ser enviado, talvez até horas. Provavelmente nesse contexto o usuário não vai esperar esse tempo todo para receber o SMS e ter uma experiência frustrante com a aplicação.
Essas são algumas práticas que aprendi a serem evitadas ao trabalhar com background jobs, mas acredito que boa parte dessas idéias podem ser aplicadas também a sistemas baseados em eventos e baseados em troca de mensagens assíncronas. Porém, como eu disse no começo do post, as idéias e práticas descritas aqui são baseadas somente na minha experiência, majoritariamente em aplicações ruby on rails usando o sidekiq como ferramenta de processamento assíncrono das tarefas. Imagino tenham outras práticas que são importantes serem evitadas baseado em outras experiências, linguagens, frameworks e contextos.
Top comments (0)