Atualizações ao vivo de jobs em background no Rails 8 com Turbo Streams
O botão de scan funcionava, mas parecia incompleto. Clico nele, vejo “Scan started.” piscar no topo, depois fico olhando para uma linha ainda mostrando “idle” até apertar atualizar. O job de scan rodava direitinho; posts eram encontrados, estados eram atualizados. A página é que não fazia ideia.
Essa é a metade chata dos jobs assíncronos: o job funciona, mas a interface não sabe. Você faz polling ou você faz push. O Rails tem a história do push há um tempo com o ActionCable e o Turbo Streams. Eu vinha adiando isso porque esperava mais trabalho de configuração do que realmente precisou.
O que eu estava construindo
Duas atualizações separadas, ambas mirando a mesma linha da tabela:
- Clica em Scan → a linha muda para “Scanning…” na hora (sem recarregar)
- O job termina → a linha atualiza para o resultado ou erro (sem recarregar)
Ambas funcionam substituindo o <tr> da linha usando um id de DOM estável. A primeira atualização vem do controller respondendo com um Turbo Stream. A segunda vem do job transmitindo um Turbo Stream pelo ActionCable.
Extraindo o partial
O índice de blogs tinha toda a marcação da linha embutida direto no loop .each. Isso funciona quando as linhas são estáticas, mas o Turbo precisa de um alvo de DOM estável. O primeiro passo foi extrair tudo para _blog.html.erb e dar um id ao <tr>:
<tr id="<%= dom_id(blog) %>">
...
</tr>
dom_id é o helper nativo do Rails que retorna "blog_1" para Blog.find(1). Uma vez que o partial existe, o índice pode usar render blog diretamente; o Rails encontra _blog.html.erb e passa blog como local automaticamente.
Inscrevendo a página
Dentro do loop da coleção, uma linha inscreve cada linha no seu próprio canal do ActionCable:
<% @blogs.each do |blog| %>
<%= turbo_stream_from blog %>
<%= render blog %>
<% end %>
turbo_stream_from blog renderiza um elemento <turbo-cable-stream-source> que abre uma conexão WebSocket restrita àquele registro. Quando um broadcast chega, o navegador aplica a ação do Turbo Stream diretamente.
O lado do controller
button_to envia como um Turbo Form por padrão, então o controller pode responder com um stream:
def scan
@blog.update!(scan_state: :running)
BlogScanJob.perform_later(@blog)
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace(@blog, partial: "blogs/blog", locals: { blog: @blog }) }
format.html { redirect_to blogs_path }
end
end
Definir scan_state: :running antes de enfileirar significa que a resposta do Turbo Stream já renderiza o estado “Scanning…” na hora. O fallback format.html mantém tudo funcionando sem JavaScript.
O lado do job
Transmitir é uma chamada de método por transição de estado:
Turbo::StreamsChannel.broadcast_replace_to(
blog,
target: dom_id(blog),
partial: "blogs/blog",
locals: { blog: blog }
)
dom_id é um helper de view que mora em ActionView::RecordIdentifier. Jobs não incluem módulos de view por padrão, então:
class BlogScanJob < ApplicationJob
include ActionView::RecordIdentifier
...
end
Tem uma pegadinha com o callback discard_on. Ele roda em nível de classe, não de instância. Chamar broadcast_blog(blog) dentro do bloco falha; você precisa chamar via job.broadcast_blog(blog), o que significa que o método precisa ser público:
discard_on SomeError do |job, error|
blog = job.arguments.first
blog.update!(...)
job.broadcast_blog(blog) # via job., not bare method call
end
A pegadinha que me custou um ciclo de teste inteiro
A atualização do lado do controller funcionou na hora. A atualização pós-job nunca disparava.
A causa raiz estava em config/cable.yml, documentada por um comentário que eu não tinha lido:
# Async adapter only works within the same process...
development:
adapter: async
bin/dev roda o Puma e o Solid Queue como processos separados. O adapter async é em memória, restrito a um único processo. Broadcasts do worker do Solid Queue vão para um barramento sem saída que o servidor web nunca lê.
A correção foi uma única chave de configuração:
development:
adapter: solid_cable
polling_interval: 0.1.seconds
message_retention: 1.day
O Solid Cable usa o SQLite como broker de mensagens. O worker escreve uma linha em solid_cable_messages, o servidor web faz polling e entrega pelo WebSocket. Ele já estava instalado (produção usa) e a migration já tinha rodado. Eu só não tinha conectado o ambiente de desenvolvimento para usá-lo.
O que me surpreendeu
Quão pouco código isso levou depois que entendi as peças. A parte de conexão do Turbo foi provavelmente 30 linhas no total. A descoberta do adapter async foi a parte lenta.
Além disso: o bloco em nível de classe do discard_on é uma armadilha fácil de cair. Parece estar no mesmo escopo dos métodos de instância, mas não está. Se você tem callbacks de tratamento de erro que precisam transmitir, torne o método de broadcast público.
Leitura relacionada
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Um 500 escondido dentro das rotas isoladas de uma engine montada
O painel de jobs retornava um 500 em vez de uma página de login: helpers de rota sem qualificação resolvem contra a engine, não contra a aplicação. Uma linha, mais o gêmeo dormente dela.
Apagando código morto, e pegando um motivo errado para uma resposta certa
Uma issue de limpeza com uma justificativa errada, uma varredura de documentação que não era necessária, e a credencial órfã que uma revisão pegou.
Você também pode achar útil
Proton VPN
VPN comercial com filtragem NetShield e interruptor de desligamento automático.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisNordPass
Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.
Como afiliado da NordPass, ganho com compras qualificadas.
Saiba maisAdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba mais