This is Part 15 of the Ruby for AI series. We just covered ActionCable for real-time features. Now let's talk about the engine behind every serious AI feature: background jobs.
AI API calls are slow. Embedding generation takes time. PDF processing blocks threads. You never, ever want your web request sitting there waiting for OpenAI to respond. Background jobs solve this completely.
Active Job: The Interface
Active Job is Rails' unified API for background processing. It's an abstraction layer — you write jobs once, then plug in any backend: Sidekiq, Solid Queue, Good Job, or others.
# Generate a job
rails generate job ProcessDocument
# app/jobs/process_document_job.rb
class ProcessDocumentJob < ApplicationJob
queue_as :default
def perform(document_id)
document = Document.find(document_id)
content = document.file.download
# Call AI to summarize
response = OpenAI::Client.new.chat(
parameters: {
model: "gpt-4",
messages: [{ role: "user", content: "Summarize: #{content}" }]
}
)
document.update!(
summary: response.dig("choices", 0, "message", "content"),
processed_at: Time.current
)
end
end
Enqueue it from anywhere:
# Fire and forget
ProcessDocumentJob.perform_later(document.id)
# With a delay
ProcessDocumentJob.set(wait: 5.minutes).perform_later(document.id)
# On a specific queue
ProcessDocumentJob.set(queue: :ai_processing).perform_later(document.id)
The controller returns instantly. The job runs in a separate process. The user never waits.
Solid Queue: Rails 8's Default
Rails 8 ships with Solid Queue as the default backend. No Redis needed — it uses your existing database.
# Already included in Rails 8 apps, but if you need to add it:
bundle add solid_queue
rails solid_queue:install
rails db:migrate
Configure it:
# config/queue.yml
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 5
processes: 2
development:
<<: *default
production:
<<: *default
workers:
- queues: "default,mailers"
threads: 5
processes: 2
- queues: "ai_processing"
threads: 3
processes: 1
Start it:
# Development (runs with your Rails server)
bin/jobs
# Production (as a separate process)
bundle exec rake solid_queue:start
The beauty of Solid Queue: zero infrastructure overhead. Your database handles job storage. For most apps, this is plenty.
Sidekiq: The Heavy Hitter
When you need serious throughput — thousands of jobs per second, complex retry logic, scheduled jobs — Sidekiq is the standard.
# Gemfile
gem "sidekiq"
# config/application.rb
config.active_job.queue_adapter = :sidekiq
# config/sidekiq.yml
:concurrency: 10
:queues:
- [critical, 3]
- [default, 2]
- [ai_processing, 1]
# Start Sidekiq
bundle exec sidekiq
Sidekiq uses Redis and processes jobs in threads, making it extremely fast. The numbers after queue names are weights — critical gets 3x the attention of ai_processing.
Job Patterns for AI Work
Pattern 1: Chain of Jobs
AI workflows often have multiple steps. Chain them:
class GenerateEmbeddingJob < ApplicationJob
queue_as :ai_processing
def perform(document_id)
document = Document.find(document_id)
embedding = OpenAI::Client.new.embeddings(
parameters: {
model: "text-embedding-3-small",
input: document.content
}
)
document.update!(
embedding: embedding.dig("data", 0, "embedding")
)
# Chain: after embedding, find similar docs
FindSimilarDocumentsJob.perform_later(document_id)
end
end
Pattern 2: Progress Tracking
Users want to know what's happening. Track progress with ActionCable:
class BulkProcessJob < ApplicationJob
queue_as :ai_processing
def perform(batch_id)
batch = Batch.find(batch_id)
items = batch.items.unprocessed
items.each_with_index do |item, index|
process_item(item)
# Broadcast progress
ActionCable.server.broadcast(
"batch_#{batch_id}",
{ progress: ((index + 1).to_f / items.count * 100).round,
processed: index + 1,
total: items.count }
)
end
batch.update!(completed_at: Time.current)
end
private
def process_item(item)
# Your AI processing here
end
end
Pattern 3: Retry with Backoff
AI APIs have rate limits. Handle them gracefully:
class AiApiJob < ApplicationJob
queue_as :ai_processing
retry_on Faraday::TooManyRequestsError,
wait: :polynomially_longer,
attempts: 5
retry_on Faraday::TimeoutError,
wait: 10.seconds,
attempts: 3
discard_on ActiveRecord::RecordNotFound
def perform(record_id)
record = Record.find(record_id)
# API call that might fail
end
end
wait: :polynomially_longer spaces out retries: ~3s, ~18s, ~83s, ~293s. Perfect for rate limits. discard_on skips the job entirely if the record was deleted while waiting.
Pattern 4: Unique Jobs
Don't process the same document twice simultaneously:
class ProcessDocumentJob < ApplicationJob
queue_as :ai_processing
before_enqueue do |job|
document_id = job.arguments.first
key = "processing_document_#{document_id}"
throw(:abort) if Rails.cache.exist?(key)
Rails.cache.write(key, true, expires_in: 30.minutes)
end
after_perform do |job|
document_id = job.arguments.first
Rails.cache.delete("processing_document_#{document_id}")
end
def perform(document_id)
# Process document
end
end
Queues: Separate Your Work
Keep AI processing separate from your regular app work:
# Fast, user-facing stuff
class SendNotificationJob < ApplicationJob
queue_as :default
end
# Slow AI calls
class GenerateSummaryJob < ApplicationJob
queue_as :ai_processing
end
# Critical stuff that can't wait
class ChargePaymentJob < ApplicationJob
queue_as :critical
end
Run separate workers per queue so a flood of AI jobs doesn't block your notifications.
What's Next
Background jobs + ActionCable + Turbo = the complete real-time AI pipeline. Your user submits a prompt, a job picks it up, calls the AI API, and streams the result back — all without blocking a single web request.
Next up: Rails + OpenAI API — we'll build a full chat interface with streaming responses, putting everything we've learned together.
Part 15 of the Ruby for AI series. Code runs on Rails 8+ with Ruby 3.2+.
Top comments (0)