Actualizaciones en vivo desde jobs en segundo plano en Rails 8 con Turbo Streams
El botón de escaneo funcionaba, pero se sentía incompleto. Hacer clic, ver “Scan started.” destellar arriba, y después quedar mirando una fila que seguía mostrando “idle” hasta refrescar la página. El job de escaneo corría bien; se encontraban posts, se actualizaban estados. La página simplemente no tenía idea.
Esta es la mitad aburrida de los jobs asíncronos: el job funciona, pero la interfaz no se entera. O se hace polling, o se empuja la actualización. Rails tiene la historia del push desde hace tiempo con ActionCable y Turbo Streams. Lo había estado postergando porque esperaba que hiciera falta más configuración de la que en realidad hizo falta.
Lo que estaba construyendo
Dos actualizaciones separadas, ambas apuntando a la misma fila de la tabla:
- Clic en Scan → la fila cambia a “Scanning…” al instante (sin recargar)
- El job termina → la fila se actualiza con el resultado o el error (sin recargar)
Ambas funcionan reemplazando el <tr> de la fila usando un id de DOM estable. La primera actualización viene del controlador respondiendo con un Turbo Stream. La segunda viene del job transmitiendo un Turbo Stream por ActionCable.
Extrayendo el partial
El índice de blogs tenía todo el markup de la fila en línea dentro de un loop .each. Eso funciona cuando las filas son estáticas, pero Turbo necesita un objetivo de DOM estable. El primer paso fue extraer todo a _blog.html.erb y darle un id al <tr>:
<tr id="<%= dom_id(blog) %>">
...
</tr>
dom_id es el helper integrado de Rails que devuelve "blog_1" para Blog.find(1). Una vez que existe el partial, el índice puede usar render blog directamente; Rails encuentra _blog.html.erb y pasa blog como local automáticamente.
Suscribiendo la página
Dentro del loop de la colección, una línea suscribe cada fila a su propio canal de ActionCable:
<% @blogs.each do |blog| %>
<%= turbo_stream_from blog %>
<%= render blog %>
<% end %>
turbo_stream_from blog renderiza un elemento <turbo-cable-stream-source> que abre una conexión WebSocket limitada a ese registro. Cuando llega un broadcast, el navegador aplica la acción del Turbo Stream directamente.
El lado del controlador
button_to envía como un Turbo Form por defecto, así que el controlador puede responder con un 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
Establecer scan_state: :running antes de encolar el job hace que la respuesta del Turbo Stream renderice el estado “Scanning…” de inmediato. El fallback de format.html mantiene todo funcionando sin JavaScript.
El lado del job
Transmitir es una llamada de método por cada transición de estado:
Turbo::StreamsChannel.broadcast_replace_to(
blog,
target: dom_id(blog),
partial: "blogs/blog",
locals: { blog: blog }
)
dom_id es un helper de vista que vive en ActionView::RecordIdentifier. Los jobs no incluyen módulos de vista por defecto, así que:
class BlogScanJob < ApplicationJob
include ActionView::RecordIdentifier
...
end
Hay una trampa con el callback discard_on. Corre a nivel de clase, no a nivel de instancia. Llamar a broadcast_blog(blog) dentro del bloque falla; hay que llamarlo vía job.broadcast_blog(blog), lo que significa que el método tiene que 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
La trampa que me costó un ciclo completo de pruebas
La actualización del lado del controlador funcionó de inmediato. La actualización posterior al job nunca se disparó.
La causa raíz estaba en config/cable.yml, documentada por un comentario que no había leído:
# Async adapter only works within the same process...
development:
adapter: async
bin/dev corre Puma y Solid Queue como procesos separados. El adaptador async es en memoria, limitado a un solo proceso. Los broadcasts del worker de Solid Queue van a un bus sin salida que el servidor web nunca lee.
El arreglo fue una sola clave de configuración:
development:
adapter: solid_cable
polling_interval: 0.1.seconds
message_retention: 1.day
Solid Cable usa SQLite como broker de mensajes. El worker escribe una fila en solid_cable_messages, el servidor web hace polling y entrega al WebSocket. Ya estaba instalado (producción lo usa) y la migración ya había corrido. Solo no había conectado el entorno de desarrollo para usarlo.
Lo que me sorprendió
Cuánto poco código hizo falta una vez que entendí las piezas. El cableado de Turbo en sí fueron probablemente 30 líneas en total. Descubrir el problema del adaptador async fue la parte lenta.
Además: el bloque a nivel de clase de discard_on es una trampa fácil. Parece estar en el mismo scope que los métodos de instancia, pero no lo está. Si hay callbacks de manejo de errores que necesitan transmitir, el método de broadcast tiene que ser público.
Lecturas relacionadas
Qué pasa cuando un job transmite y nadie escucha
Cerrando el ciclo de la imagen destacada: frontmatter que solo inserta, una validación que detectó drift real, y un broadcast sin oyentes.
Una corrección de desfase de documentación que no fue tan aburrida como sonaba
Tres puntos de auditoría que se volvieron algo más: una afirmación a medio corregir, un restablecimiento de contraseña muerto en silencio, y un correo de staging que enlazaba a producción.
Un 500 escondido dentro de las rutas aisladas de un engine montado
El dashboard de jobs devolvía un 500 en vez de una página de login: los route helpers sin calificar se resuelven contra el engine, no contra la app. Una línea, más su gemela dormida.
También te podría ser útil
Proton VPN
VPN comercial con filtrado NetShield e interruptor de apagado automático.
Como socio de Proton, obtengo ingresos por las compras que califican de los servicios de privacidad y seguridad de Proton (Pass, Mail, VPN, Drive).
Más informaciónNordPass
Gestor de contraseñas del equipo detrás de NordVPN, con un plan gratuito.
Como afiliado de NordPass, obtengo ingresos por las compras que califican.
Más informaciónAdGuard para iOS
Bloqueo de anuncios y rastreadores en todo el sistema en iOS, sin necesidad de un servidor DNS aparte.
Como afiliado de AdGuard, obtengo ingresos por las compras que califican.
Más información