Uma "pegadinha" nas transactions do Rails
O problema
Recentemente, tivemos um problema em que um serviço estava gerando registros inválidos no banco de dados. A reclamação era que, mesmo apresentando erro, tarefas estavam sendo parcialmente atualizadas no banco de dados.
O problema foi fácil de ser identificado. Se analisarmos o seguinte esboço de código do serviço, vemos que um mesmo registro é atualizado mais de uma vez:
update_task
update_meeting if task.meeting?
end
# ...
@task.save!
# ...
end
# ...
@task.save!
# ...
end
end
end
A solução
O erro ocorria no segundo método, update_meeting
, porém um @task.save!
já tinha sido chamado anteriormente, realizando commit
no banco de dados. Para resolver, bastou "embrulhar" os métodos em uma transaction:
ActiveRecord::Base.transaction do
update_task
update_meeting if task.meeting?
end
end
Quando colocamos diversas operações dentro de uma transaction, o commit no banco de dados só é feito ao final da transaction e apenas se nada de errado ocorrer. Desta forma, se por qualquer motivo o segundo @task.save!
falhar, o primeiro NÃO será salvo no banco. Problema resolvido!
O problema da solução
Alguns dias depois chega uma nova reclamação de que eventos de tarefas do tipo meeting não estão sendo gerados...
Se analisarmos o código que deveria gerar o evento:
# ...
after_commit :register_event, if: :saved_change_to_done?
# ...
Event.create # ...
end
Acontece que o atributo done
é alterado no primeiro método, update_task
, porém o método register_event
simplesmente não estava mais sendo chamado após a implementaçao da transaction.
Testando aqui e acolá, em algum momento acabei removendo o if: :saved_change_to_done?
e o método foi chamado.
Aí que a ficha caiu: Atributos do ActiveModel::Dirty são resetados cada vez que um método que altera um modelo é chamado (save!
no nosso caso), independentemente se está dentro de uma transaction ou não.
Então, o que ocorreu foi que ao chamar o primeiro save!
, os atributos do ActiveModel::Dirty foram apagados sem que o after_commit
fosse chamado (uma vez que não houve commit ainda). Após o segundo save!
ser finalizado, a transaction é concluída, gerando um commit no banco de dados e assim os after_commit
são chamados, porém, como os atributos do ActiveModel::Dirty do primeiro save!
foram resetados, apenas os atributos do segundo save!
é que estarão disponíveis para consulta, logo, saved_change_to_done?
retorna false
, uma vez que não houve mudança no atributo done
no último save!
.
A solução da solução
Para resolver este problema, há basicamente duas opções:
- Não alterar um mesmo registro mais de uma vez em uma transaction
- Utilizar a gem ar_transaction_changes
Apesar da primeira opção parecer ser óbvia e até uma "boa prática", alterar um mesmo registro várias vezes em um serviço pode ser comum, principalmente se você segue o princípio DRY, pois você pode estar reutilizando outros serviços dentro deste (e estes serviços alterarem este mesmo registro).
A ressalva quanto a segunda opção fica aos possíveis conflitos que esta gem pode ter com outras gems (ler o README) e, que, dependendo do tamanho e idade da sua base de código, pode ser que mais alguém se deparou com este problema e simplesmente contornou ele, sem necessariamente ter entendido o que aconteceu. Se for este o caso, adicionar a gem pode fazer com que mais callbacks sejam chamados de forma inesperada, podendo ocasionar em novos bugs.