Saltar al contenido
Development

Actualizaciones en vivo desde jobs en segundo plano en Rails 8 con Turbo Streams

Por Victor Da Luz
railsturbohotwiredev-logblog-manager

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:

  1. Clic en Scan → la fila cambia a “Scanning…” al instante (sin recargar)
  2. 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

Development

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.

Leer