A escrita que não está na sua transação

Prévia grátis 5 min de leitura

Esta aplicação enfileira trabalho em segundo plano em poucos lugares: os mailers, os emails de notificação, os webhooks de parceiros e a busca do ícone do parceiro. O mais simples deles é app/services/admin/partners/save_partner_service.rb, mostrado aqui por inteiro:

module Admin
  module Partners
    class SavePartnerService
      Result = Data.define(:success, :partner, :errors)

      def self.call(partner:, params:)
        partner.assign_attributes(params)

        if partner.save
          ::Partners::FetchSiteIconJob.perform_later(partner.id) unless partner.logo.attached?
          Result.new(success: true, partner: partner, errors: [])
        else
          Result.new(success: false, partner: partner, errors: partner.errors.full_messages)
        end
      end
    end
  end
end

Duas escritas acontecem ali, uma em cada linha. partner.save grava uma linha. perform_later grava um job. A pergunta interessante é onde cada uma delas cai.

Quatro bancos de dados, não um

O config/database.yml deste app declara quatro conexões em desenvolvimento, e cada uma é um banco separado:

# config/database.yml, desenvolvimento (nomes de banco encurtados)
development:
  primary:
    database: app_development
  cache:
    database: app_development_cache
  queue:
    database: app_development_queue
  cable:
    database: app_development_cable

O Solid Queue aponta explicitamente para o banco da fila, em config/environments/development.rb, test.rb e production.rb:

config.solid_queue.connects_to = { database: { writing: :queue } }

Então partner.save grava no banco primário, e perform_later grava uma linha no banco da fila. Bancos diferentes, conexões diferentes, dois commits separados.

Por que isso rende um curso inteiro

O Solid Queue guarda jobs numa tabela de banco e não no Redis, o que soa como se devesse colocar o enfileiramento dentro da sua transação. Não coloca, porque a tabela vive em outro banco. Um BEGIN na conexão primária não tem autoridade sobre a conexão da fila. Envolver as duas linhas em ActiveRecord::Base.transaction não mudaria isso.

Esta é a forma de que trata o resto do curso. Antes de ganhar nome de padrão, aqui está ela sem rodeios: duas escritas, em dois sistemas, que não podem ser tornadas atômicas.

A falha que este código tem

Leia a ordem de novo. partner.save faz commit primeiro. Só então perform_later roda.

Suponha que o processo morra entre essas duas linhas, por exemplo o container é derrubado no meio da requisição ou a máquina perde energia. A linha do partner está commitada, e o job nunca foi enfileirado. Nada tenta de novo e nada registra em log. O partner fica sem o ícone do site que o FetchSiteIconJob teria buscado, até um admin salvá-lo de novo por acaso.

Ninguém vê erro, porque do ponto de vista da requisição o save deu certo, e deu mesmo.

Há uma variante mais barulhenta. Se o banco da fila recusar a conexão, perform_later levanta erro depois que o partner já foi commitado. O admin recebe uma página de erro por um save que aconteceu.

Aqui o que está em jogo é um logo faltando, o que é uma coisa ótima de se perder. É isso que faz dele um bom primeiro exemplo, e não um alarmante. O mecanismo é o mesmo quer o job perdido envie um recibo, cobre um cartão ou busque um favicon.

A outra ordem não é melhor

A correção óbvia é enfileirar primeiro e salvar depois, para o job nunca se perder. Isso troca uma falha por outra pior. Para um partner novo, ainda não existe id para passar. Para um existente, o job pode rodar antes de o save fazer commit, ou depois de um save que falhou. O FetchSiteIconJob#perform começa com ::Partners::Partner.find_by(id: partner_id), e quando isso devolve nil a guarda da linha seguinte não faz nada.

Nenhuma das duas ordens é segura, e não existe uma terceira. Esse é o problema inteiro.

Mais um detalhe que vale saber

Desenvolvimento nem usa o Solid Queue. O próprio development.rb deste app define:

config.active_job.queue_adapter = :async

O :async roda jobs num pool de threads dentro do processo e mantém sua fila em memória, então um job enfileirado em desenvolvimento se perde no restart, sem banco nenhum envolvido. Produção e a suíte de testes usam :solid_queue. Reproduzir um job perdido localmente exige, portanto, uma montagem deliberada; uma queda bem na hora certa não basta.

O que as sete aulas cobrem

A aula 2 percorre por que nenhuma ordem de duas escritas é segura, e por que "envolver numa transação" não está disponível entre dois sistemas. A aula 3 apresenta o outbox em si: gravar a mensagem como uma linha comum, na mesma transação que o dado, para existir só uma escrita a tornar atômica. A aula 4 cobre o relay que drena essa tabela e o que as quedas dele significam. A aula 5 trata da garantia que você obtém, at-least-once, e das duplicatas que vêm junto. A aula 6 calcula o que adotar isso aqui custaria, contra o schema real. A aula 7 defende o outro lado, porque a maioria das aplicações Rails não deveria construir um.

A versão curta, que a última aula sustenta: a janela de ícone faltando deste app é real, e um outbox é quase certamente a ferramenta errada para ela.

Aula 1 de 7 · grátis

Continue com a aula 2

Por que duas escritas não podem ser atômicas

Explica por que nenhuma ordem de duas escritas é segura e por que after_commit e retentativas deixam a janela aberta.

Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.

Digite para buscar...