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
La auditoría de seguimiento que pidió una revisión de código
Un error de turbo_stream corregido dos veces, cuatro instancias más de la misma forma encontradas al aplicar un discriminador en vez de una regla general, y la documentación que todavía enseñaba la versión rota.
Feedback en curso para las acciones de imagen hero
Un ticket de pulido que se dividió en dos problemas, una bandera persistida que habría dejado un spinner pegado para siempre, y un error de orden de ramas que tres ángulos de revisión señalaron de forma independiente.
El bug de normalización que solo aparece con etiquetas hechas de nada
Un normalizador basado en strip se topa con una etiqueta de puro signo de puntuación: string vacío como clave de hash, sustitución de etiqueta equivocada, y un autocompletado que hace match con todo. Tres síntomas, una sola causa raíz.