DEV Community

Inside SumUp Brazil
Inside SumUp Brazil

Posted on

Transações com Ecto

Transações em bancos de dados são um tema recorrente no dia a dia de pessoas desenvolvedoras de software. Em bancos de dados SQL, muitas vezes enfrentamos situações onde alterações em múltiplas tabelas são necessárias para completar uma transação de negócio. Para garantirmos a integridade da operação, transações são necessárias de forma que, em caso de alguma das operações falhar, independente de em qual momento a falha ocorrer, todas as anteriores sejam revertidas e as pendentes não sejam executadas.

Imaginando agora um exemplo em uma arquitetura de microsserviços que trocam mensagens através de um broker (como o Apache Kafka, ou RabbitMQ), tanto a persistência de uma operação no banco de dados local de cada serviço, quanto o envio de uma mensagem relacionada para um broker precisam ter as mesmas garantias em casos de falha. Alguns patterns para lidar com esse problema já são bem conhecidos e utilizados em casos mais complexos, como Two-phase commit, Sagas e Transactional Outbox.

Para casos mais simples, porém, uma forma de lidar com ele é incluir como parte do código que vai executar em um contexto de transação, a publicação dos eventos para seus destinos. Nos casos de uma aplicação escrita em Elixir, com um banco de dados SQL, temos algumas alternativas de escrever essa transação. Vamos ver duas delas.

Utilizando transações com Ecto
Utilizando uma simples transação do Ecto, basta invocarmos a função Ecto.Repo.transaction/2 e, dentro da função que é passada como parâmetro, programarmos as expressões na ordem em que queremos que elas sejam executadas. O trecho de código abaixo exibe um exemplo. Nele estamos realizando uma inserção e em seguida publicando uma mensagem.

def update_and_publish(%Settlement{} = settlement, %{} = updated_params) do
  Repo.transaction(fn ->
    with {:ok, changed_settlement} <-
           Repo.update(Settlement.update_changeset(settlement, updated_params)),
         {:ok, _} <- SettlementPublisher.publish(:settlement_changed, changed_settlement) do
      {:ok, changed_settlement}
    else
      {:error, reason} = err ->
        Repo.rollback(reason)
        err
    end
  end)
end
Enter fullscreen mode Exit fullscreen mode

O problema com esse código é que ele tende a ser verboso e requer explicitamente que façamos tratamento das operações para garantirmos que elas finalizaram com sucesso e decidirmos pelo commit ou rollback da transação.

Introduzindo o Ecto.Multi
O módulo Ecto.Multi permite que se construa operações em cima de um Ecto.Repo utilizando de composição de funções para indicar a sequência em que elas devem ser executadas. A vantagem dessa estratégia é que não há necessidade de se controlar os resultados das operações e o commit ou rollback da transação explicitamente. O Ecto.Multi garante que todas as funções compostas são executadas dentro de um contexto de transação e que o commit dela só aconteça caso todas finalizem com sucesso. Em caso de qualquer uma falhar, o rollback será executado implicitamente.

Além dessa facilidade, o Ecto.Multi também permite dar nomes a cada uma das operações, de modo que as subsequentes recebam uma redução do estado da operação que está sendo composto a cada execução de uma expressão. Isso se mostra muito útil em casos onde, para executar uma expressão, é necessário utilizar dados que foram gerados em instruções anteriores. Desse modo, o código Elixir tende a apresentar um aspecto muito mais declarativo e de composição de funções.

A maioria das operações que geram mudanças no banco de dados presentes no módulo Ecto.Repo, também são encontradas no Ecto.Multi, como por exemplo insert, update e delete.

Além delas, o módulo também fornece a função run que pode ser executada com qualquer operação arbitrária, não necessariamente gerando mudança no database, mas como por exemplo, realizando uma publicação de uma mensagem de evento. Essa função deve retornar ou uma tupla {:ok, value}, ou uma tupla {:error, value}. No primeiro caso, o Multi vai considerar a operação com sucesso e vai continuar com a transação. No segundo, o Multi considera a transação como falha e vai parar a sua continuidade, executando o rollback para as operações que finalizaram com sucesso anteriormente.

Utilizando o Ecto.Multi
É hora de refatorar o exemplo anterior para agora utilizar o Ecto.Multi. No trecho de código abaixo é possível notar um código usando a tão amada composição com o pipe operator que Elixir oferece.

def update_and_publish(%Settlement{} = settlement, %{} = updated_params) do
  transaction =
    Multi.new()
    |> Multi.update(:update, Settlement.update_changeset(settlement, updated_params))
    |> Multi.run(:publish_message, fn _repo, %{update: changed_settlement} ->
      SettlementPublisher.publish(:settlement_changed, changed_settlement)
    end)
    |> Repo.transaction()

  case transaction do
    {:ok, result} -> {:ok, result.update}
    {:error, _, failed_value, _} -> {:error, failed_value}
  end
end
Enter fullscreen mode Exit fullscreen mode

Observe que na chamada para Multi.run, recebemos na função anônima que estamos enviando como argumento um map em que estamos fazendo pattern match na chave update. Esse mapa contém as mudanças que foram executadas anteriormente, nomeadas de acordo com o atom que passamos como argumento para cada chamada de função dentro de um contexto do Multi. Nesse caso, a chave update contém o resultado da função Multi.update, que chamamos anteriormente.

Para formatar um retorno agradável a quem está chamando a nossa função, em caso de sucesso, procuramos o resultado da chamada de update, conforme nomeamos na composição do Multi. Em caso de falha, o Multi nos retorna uma tupla de 4 valores, contendo o atom :error, o nome da operação que falhou, o valor retornado e as mudanças que haviam terminado com sucesso, mas que sofreram o rollback junto com a transação. No nosso caso, estamos apenas interessados no valor de falha, que pode ou ser relacionado ao update no database, ou à publicação da mensagem.

Conclusões
A lib Ecto se destaca em meio à outras que também lidam com acesso e manipulação de dados através de código. A simplicidade e a elegância que oferece à pessoa desenvolvedora é fora de série. Como vimos nesse artigo, não é diferente com transações, principalmente quando trabalhamos com o Ecto.Multi, que oferece uma forma idiomática e simplificada de lidar com elas.

É importante perceber que com essa estratégia de encapsular na transação tanto a alteração no banco de dados e a publicação da mensagem, apenas garantimos que será tudo ou nada em nosso database local. No caso da publicação da mensagem, ela pode completar com sucesso, mas no momento de fazer o commit da transação, termos uma falha no database.

Quando esse tipo de problema ocorre, se for executada uma retentativa nessa função e desta vez a transação completar com sucesso, teremos a mesma mensagem sendo publicada duas vezes. Por isso, é muito importante considerar em uma arquitetura de microsserviços que as operações sejam idempotentes, ou seja, que se ocorrerem repetidamente, não afetarão o resultado esperado.

Também é importante perceber que estamos fazendo uma transação composta de funções simples e que devem durar poucos milisegundos. Em casos onde há expectativa de operações mais onerosas em termos de tempo de execução, não é recomendável manter uma transação do banco de dados aberta, pois há riscos envolvidos, como timeouts. Nesses casos, o pattern de Transactional Outbox citado anteriormente seria mais adequado.

Por: Daniel Pilon, Engineering Manager

Quer uma nova oportunidade de carreira com desafios globais? Confira nossas vagas em Engenharia e Produto e conheça mais sobre a SumUp: https://go.sumup.com.br/eb-dev-to-vagas

Oldest comments (0)