Issue with turbo frame, turbo stream on record update after background job process

I am having issue using turbo frame and and turbo stream:

There is a detailed version of the implementations and the logs.

Current setup

Ruby 3.2.0
Rails 8.0.1

# config/routes.rb
Rails.application.routes.draw do
  mount MissionControl::Jobs::Engine, at: "/jobs"
  get "up" => "rails/health#show", as: :rails_health_check
  resources :messages
  root "messages#index"
end
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def index
    @messages = Message.all.order(created_at: :asc)
  end

  def create
    @user_message = Message.create(
      content: params[:content],
      role: "user"
    )

    @assistant_message = Message.create(
      content: "Searching the answer for you...",
      role: "assistant"
    )

    ProcessChatMessageJob.perform_later(@user_message, @assistant_message)

    respond_to(&:turbo_stream)
  end
end
class Message < ApplicationRecord

  validates :content, presence: true, length: { minimum: 3, maximum: 1000 }

  after_create_commit do
    broadcast_append_to "messages", target: "messages", partial: "messages/message", locals: { message: self }
  end

  after_update_commit do
    broadcast_update_to "messages", target: "message_#{id}", partial: "messages/message", locals: { message: self }
  end
end
<!-- app/views/messages/index.html.erb -->
<div class="max-w-2xl mx-auto p-4 space-y-6">
  <div id="messages" class="border p-4 rounded shadow">
    <%= turbo_stream_from "messages" %>
    <% @messages.each do |message| %>
      <%= render partial: "messages/message", locals: { message: message } %>
    <% end %>
  </div>

  <div id="message_form">
    <%= render "messages/form" %>
  </div>
</div>
<!-- app/views/messages/create.turbo_stream.erb -->
<%= turbo_stream.replace "message_form" do %>
  <%= render "messages/form" %>
<% end %>
<!-- app/views/messages/_form.html.erb -->
<%= form_with url: messages_path, method: :post, data: { turbo_frame: "messages" }, class: "flex flex-col gap-2" do |f| %>
  <%= f.text_area :content, placeholder: "Enter a new message...", class: "p-2 border rounded", rows: 3 %>
  <%= f.submit "Send", class: "px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" %>
<% end %>
<!-- app/views/messages/_message.html.erb -->
<%= turbo_frame_tag dom_id(message) do %>
  <div class="p-4">
    <div class="flex my-1 <%= message.role == 'user' ? 'justify-end' : 'justify-start' %>">
      <div class="<%= message.role == 'user' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800' %> max-w-xs md:max-w-md p-4 rounded-lg shadow-lg">
        <%= markdown(message.content) %>
      </div>
    </div>
  </div>
<% end %>
# app/jobs/process_chat_message_job.rb
class ProcessChatMessageJob < ApplicationJob
  queue_as :default

  def perform(user_message, assistant_message)
    user_message_content = user_message.content

    sql_query = Ai::SqlQueryGenerator.new(user_message_content).call

    if sql_query.include?("Sorry")
      assistant_message.update!(content: sql_query)
      return
    end

    puts "Executing SQL query: #{sql_query}"

    result = execute_sql_query(sql_query)
    puts "Result of sql query: #{result}"

    formatted_answer = Ai::ResultFormatter.new(result).call
    puts "Formatted answer: #{formatted_answer}"

    assistant_message.update!(content: formatted_answer)
  end

  private

  def execute_sql_query(sql_query)
    ActiveRecord::Base.connection.execute(sql_query)
  rescue => e
    puts "Error executing SQL query: #{e.message}"
    nil
  end
end

Logs (Excerpt)


# Job Enqueue & Execution
[ActiveJob] Enqueued ProcessChatMessageJob (...) with arguments: Message/406, Message/407
...
[ActiveJob] Performing ProcessChatMessageJob (...)
Executing SQL query: SELECT ... FROM tournament_years ty ... LIMIT 20;
...
Message Update (...) UPDATE "messages" SET "content" = '# Grand Slam Wins by Legendary Tennis Players ...' WHERE "messages"."id" = 407
...

# Broadcasting Updated Message
[ActiveJob] Enqueued Turbo::Streams::ActionBroadcastJob ... with arguments: "messages", { action: :replace, target: "message_407", partial: "messages/message", locals: { message: Message/407 } }
...
[ActiveJob] Performed Turbo::Streams::ActionBroadcastJob (Job ID: ...) from SolidQueue(default) in ...ms

Detailed Explanation of the Issue

Current Behavior

On Form Submission:

The MessagesController#create action creates two records:

  1. A user message (e.g. “give me top 20 players that won the most grand slam ?”).

  2. An assistant message with the placeholder text “Searching the answer for you…”.

Both records trigger after_create_commit callbacks which broadcast an append event to the “messages” stream. In your index view, you subscribe to this stream and render all messages inside a container with id=“messages”.

Additionally, the view has a separate container (id=“message_form”) for the form, which is replaced by a turbo stream (from create.turbo_stream.erb) after submission.

After ActiveJob Processing:

The background job (ProcessChatMessageJob) receives both the user message and the assistant message. It then:

• Calls your SQL query generator and formatter.

• Updates the assistant message with the formatted answer.

• The after_update_commit callback on Message triggers a broadcast update (using broadcast_replace_later_to) targeting the turbo frame with dom_id(message).

In logs, you see an append broadcast (for creation) targeting “messages” and later a replace broadcast targeting “message_407” (for example).

Problems Observed

  1. AI Response Not Updating:

After the background job finishes processing the API response,

The assistant message is well updated inside the database.

It seems that it’s broadcasted to the stream but still the message is not being updated on the page.

My understanding of hotwire and turbo is limited and I don’t seems to see where something is going wrong.

From the logs i feel like all the messages are broadcasted rightfully

I don’t know where to look for anymore.

Anyone would have any ideas of what’s wrong about the implementation ?

Thanks,

I finally found the solution:

I had solid queue set up for development but not solid cable.

I believe that when using active job, the updates go through solid cable and not solid queue.

So setting up solid cable (on top up the setup of solid queue that i already had) fixed my issue