https://venantivs.com/rss.xml

Uma "pegadinha" nas transactions do Rails

2024-05-21

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:

module TaskServices
  class Update
    def execute
      update_task
      update_meeting if task.meeting?
    end

    def update_task
      # ...
      @task.save!
      # ...
    end

    def update_meeting
      # ...
      @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:

def execute
  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:

class Task < ApplicationRecord
  # ...
  after_commit :register_event, if: :saved_change_to_done?
  # ...

  def register_event
    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:

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.