class Document < ApplicationRecord
belongs_to :account
enum status: { pending: 0, processing: 1, ready: 2, failed: 3 }
after_create_commit :broadcast_badge
def transition_to!(new_status)
unless valid_transition?(new_status)
raise ArgumentError, "illegal transition #{status} -> #{new_status}"
end
update!(status: new_status)
broadcast_badge
end
private
def valid_transition?(new_status)
allowed = {
"pending" => %w[processing],
"processing" => %w[ready failed],
"failed" => %w[processing]
}
allowed.fetch(status, []).include?(new_status.to_s)
end
def broadcast_badge
broadcast_replace_later_to(
[account, :documents],
target: "document_#{id}_badge",
partial: "documents/badge",
locals: { document: self }
)
end
end
class ProcessDocumentJob < ApplicationJob
queue_as :default
retry_on Extraction::TransientError, wait: :polynomially_longer, attempts: 5
def perform(document)
document.transition_to!(:processing)
result = Extraction::Pipeline.new(document).run
document.update!(page_count: result.page_count, text: result.text)
document.transition_to!(:ready)
rescue StandardError => e
document.transition_to!(:failed) if document.processing?
Rails.logger.error("document #{document.id} failed: #{e.message}")
raise
end
end
class DocumentsController < ApplicationController
before_action :set_account
def create
@document = @account.documents.new(document_params.merge(status: :pending))
if @document.save
ProcessDocumentJob.perform_later(@document)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.prepend(
"documents",
partial: "documents/document",
locals: { document: @document }
)
end
format.html { redirect_to account_documents_path(@account) }
end
else
render :new, status: :unprocessable_entity
end
end
private
def set_account
@account = current_user.accounts.find(params[:account_id])
end
def document_params
params.require(:document).permit(:title, :file)
end
end
<%= turbo_stream_from [document.account, :documents] %>
<span id="<%= "document_#{document.id}_badge" %>"
class="badge badge--<%= document.status %>">
<% case document.status %>
<% when "pending" %>
<span class="dot dot--muted"></span> Queued
<% when "processing" %>
<span class="spinner" aria-hidden="true"></span> Processing
<% when "ready" %>
<span class="dot dot--ok"></span> Ready
<% when "failed" %>
<span class="dot dot--error"></span> Failed
<% end %>
</span>
This snippet shows how a background job pushes live status updates to the browser over Turbo Streams, so a status badge changes from pending to processing to ready without the user reloading. It is the standard Hotwire pattern for long-running work: the request enqueues a job and returns immediately, while the model broadcasts each state transition to a stream the page is subscribed to.
In Document model, the record owns its own broadcast logic. The status enum defines the lifecycle, and the private broadcast_badge method uses broadcast_replace_later_to to render the documents/badge partial into the DOM element whose target is document_#{id}_badge. Broadcasting is centralized in transition_to!, which validates the move using enqueue_processing! semantics and persists the new state; every write funnels through one place so no transition can silently skip a broadcast. Using broadcast_replace_later_to (the _later variant) offloads the actual render to a job, keeping the calling job's thread free and avoiding rendering inside a database transaction.
In ProcessDocumentJob, the work is wrapped so the badge is honest about failure. The job flips the document to processing, runs the real extraction, then moves to ready. A rescue block transitions to failed and re-raises so Active Job's retry machinery still sees the error — the badge reflects reality while the retry backoff proceeds. Because each transition_to! call broadcasts, the user sees the spinner appear the instant the job picks up the work, not just when it finishes.
In DocumentsController, create saves the record, enqueues ProcessDocumentJob, and renders a Turbo Stream that prepends the new row. The freshly rendered documents/badge partial subscribes to the same stream via turbo_stream_from, so the element is listening before any background broadcast arrives.
The trade-off is eventual consistency: broadcasts are best-effort over Action Cable, so a disconnected client can miss an update and show a stale badge. Rendering the current status on page load, as _badge.html.erb does, means a refresh always corrects drift. Reaching for this pattern makes sense when work is slow enough to need feedback but not worth polling for.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.