A escrita que não está na sua transação
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.
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.