Migrate Sidekiq to Solid Queue: Complete Rails Guide
Runbook for migrating an existing Sidekiq install to Solid Queue with zero downtime: job inventory, per-job rollout, retry mapping, recurring jobs, and rollback.
This is a migration runbook for teams already running Sidekiq in production who want to move to Solid Queue without losing jobs or taking downtime. If you’re starting fresh or still deciding, read the Solid Queue setup guide first - it covers installation, configuration, and when the trade-off makes sense. This post assumes you’ve made that decision and now need the exact cutover steps.
Migrating a background job backend in production is nerve-wracking. One mistake and you’re losing jobs, double-processing payments, or watching your queue explode. The plan below de-risks it: inventory first, migrate one job at a time, run both systems side by side, and keep a tested rollback at every step.
For how the two systems differ feature by feature (backend, throughput, latency, recurring jobs, monitoring, cost), see the Solid Queue vs Sidekiq comparison table in the setup guide. This runbook focuses only on what you change during the cutover.
Before You Start: Inventory & Risk Map
Start by auditing every Sidekiq job, queue, retry policy, and scheduling source before writing any migration code. Skipping this step is the most common reason migrations fail - a complete inventory prevents surprises during cutover.
Catalogue Current Jobs
Use this script to inventory your Sidekiq jobs:
# lib/tasks/job_inventory.rake
namespace :jobs do
desc "Inventory all background jobs"
task inventory: :environment do
puts "=== Active Job Classes ==="
active_job_classes = ApplicationJob.descendants
active_job_classes.each do |klass|
queue = klass.queue_name
adapter = klass.queue_adapter.class.name
puts "#{klass.name}: queue=#{queue}, adapter=#{adapter}"
end
puts "\n=== Native Sidekiq::Worker Classes ==="
sidekiq_workers = ObjectSpace.each_object(Class).select { |k| k < Sidekiq::Worker }
sidekiq_workers.each do |klass|
options = klass.get_sidekiq_options
puts "#{klass.name}: #{options.inspect}"
end
puts "\n=== Sidekiq-Cron Jobs ==="
if defined?(Sidekiq::Cron::Job)
Sidekiq::Cron::Job.all.each do |job|
puts "#{job.name}: #{job.cron} -> #{job.klass}"
end
end
end
end
Look for:
- Native
Sidekiq::Workerclasses - Need rewrite to Active Job - Custom
sidekiq_options- Queues, retries, backtrace limits - Sidekiq middleware - Custom behavior that needs porting
- Pro/Enterprise features - Unique jobs, rate limiting, batches
- Complex retry logic - Death handlers, custom backoff
Score Each Job’s Migration Risk
Once you have the inventory, categorize every job so you know what migrates cleanly and what needs work first. Migrate the low-risk rows early to build confidence; leave the high-risk rows until you’ve proven the pattern.
| Job characteristic | Migration risk | Why | What it needs |
|---|---|---|---|
| ActiveJob subclass, no Pro features | Low | Adapter swap only | self.queue_adapter = :solid_queue |
Native Sidekiq::Worker (not ActiveJob) |
Medium | No adapter override; the class is Sidekiq-specific | Rewrite as an ApplicationJob subclass |
| Custom retry / backoff logic | Medium | Sidekiq’s implicit 25 retries don’t carry over | Explicit retry_on / discard_on to match |
| Sidekiq Pro/Enterprise unique jobs | High | No built-in equivalent in Solid Queue | Map to limits_concurrency or a DB lock |
| Cron-critical (reconciliation, billing) | High | Double-enqueue or a missed run has real impact | Deploy-N / deploy-N+1 cutover, idempotency |
Anything in the High row is what you cut over last, after the rest of the system is stable on Solid Queue.
Map Scheduling Sources
Document everywhere jobs get scheduled:
Direct scheduling:
# Find all perform_later/perform_at calls
grep -r "perform_later\|perform_at\|perform_in" app/
Cron jobs:
# Check sidekiq-cron configuration
# config/initializers/sidekiq_cron.rb or
# config/schedule.yml
Enterprise periodic jobs:
# In Sidekiq Enterprise config
Sidekiq::Enterprise.configure do |config|
config.periodic do |periodic|
# Document these
end
end
Current Ops Footprint
Document how you operate Sidekiq today:
Graceful shutdown:
# Current deploy process
kill -TSTP <sidekiq_pid> # Quiet (stops accepting new jobs)
# Wait for jobs to finish
kill -TERM <sidekiq_pid> # Terminate
Monitoring:
- Sidekiq Web dashboard location
- Alert thresholds (queue depth, latency, failure rate)
- Metrics collection (AppSignal, New Relic, etc.)
Capacity:
# Current sidekiq.yml
:concurrency: 25
:queues:
- [critical, 5]
- [default, 3]
- [mailers, 2]
- [low_priority, 1]
Save this documentation. You’ll need it to configure Solid Queue equivalently.
For the conceptual differences this implies (PostgreSQL polling with SKIP LOCKED instead of Redis, queue order or job priority instead of Sidekiq weights, and config/recurring.yml instead of sidekiq-cron), the setup guide covers the architecture. One operational difference matters for the cutover: Solid Queue has no Sidekiq-style “quiet mode” (TSTP). You stop the supervisor with TERM, which waits for running jobs to finish before exiting.
Incremental Adoption Plan: Side-by-Side, Low Blast Radius
Migrate one job at a time using per-job adapter overrides. Never flip everything at once - incremental rollout is the key to zero-downtime migration.
Phase 1: Install & Scaffold (No Traffic Yet)
# Add gem
bundle add solid_queue
# Install
bin/rails solid_queue:install
# This creates:
# - config/queue.yml
# - config/recurring.yml (empty)
# - db/queue_schema.rb
# - Migration to create solid_queue tables
Configure separate queue database (recommended):
# config/database.yml
production:
primary:
<<: *default
database: myapp_production
queue:
<<: *default
database: myapp_queue_production
migrations_paths: db/queue_migrate
# Create queue database
RAILS_ENV=production bin/rails db:create:queue
RAILS_ENV=production bin/rails db:migrate:queue
Start Solid Queue worker (separate from Sidekiq):
# In a separate process/container
bin/jobs
At this point, Solid Queue is running but processing nothing. All jobs still go through Sidekiq.
Phase 2: Per-Job Opt-In
This is the magic. Migrate one job at a time.
# app/jobs/low_risk_job.rb
class LowRiskJob < ApplicationJob
# Override adapter for just this job
self.queue_adapter = :solid_queue
queue_as :default
def perform(user_id)
# Job logic
end
end
Keep global adapter as Sidekiq:
# config/application.rb
config.active_job.queue_adapter = :sidekiq # Still default
Now LowRiskJob.perform_later(123) goes to Solid Queue. Everything else goes to Sidekiq.
Start with safe jobs:
- Non-critical background tasks
- Jobs that can be retried safely
- Low-volume jobs
- Jobs with good monitoring
Avoid migrating first:
- Payment processing
- Critical notifications
- High-volume jobs
- Jobs with complex retry logic
Phase 3: Flip Active Job Globally
After a few weeks of incremental migration:
# config/application.rb
config.active_job.queue_adapter = :solid_queue # Now default
For jobs that must stay on Sidekiq (temporarily):
class LegacyJob < ApplicationJob
self.queue_adapter = :sidekiq # Explicit override
def perform
# Will migrate later
end
end
Native Sidekiq::Worker classes keep running until you rewrite them:
# This still works, processes via Sidekiq
class OldSidekiqWorker
include Sidekiq::Worker
def perform
# Legacy code
end
end
Queue Naming & Routing: Keep Behavior the Same
Map your Sidekiq topology to Solid Queue.
Queue Mapping
Sidekiq queues (from earlier inventory):
:queues:
- [critical, 5] # ~42% of cycles
- [default, 3] # ~25%
- [mailers, 2] # ~17%
- [low_priority, 1] # ~8%
Equivalent Solid Queue topology:
# config/queue.yml
production:
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
# Critical: 2 processes, 5 threads each = 10 workers
- queues: critical
threads: 5
processes: 2
polling_interval: 0.1
# Default: 2 processes, 3 threads each = 6 workers
- queues: default
threads: 3
processes: 2
polling_interval: 1
# Mailers: 1 process, 4 threads = 4 workers (I/O bound)
- queues: mailers
threads: 4
processes: 1
polling_interval: 2
# Low priority: 1 process, 2 threads = 2 workers
- queues: low_priority
threads: 2
processes: 1
polling_interval: 5
Capacity comparison:
- Sidekiq: 25 concurrent jobs (from
:concurrency: 25) - Solid Queue: 10 + 6 + 4 + 2 = 22 concurrent jobs
Adjust threads/processes to match your capacity needs.
Keep Queue Names Stable
# DON'T change queue names during migration
class ImportantJob < ApplicationJob
queue_as :critical # Keep existing name
def perform
# ...
end
end
Changing queue names during migration causes confusion. Keep names identical.
Retries & Error Handling: Match Semantics Explicitly
Solid Queue has no automatic retries. Every retry must be declared explicitly with Active Job’s retry_on and discard_on. This is the single biggest source of migration bugs - jobs that silently retried 25 times under Sidekiq will fail once and stop under Solid Queue.
What Sidekiq Did Implicitly
Sidekiq automatically retries failed jobs ~25 times over ~21 days with exponential backoff, then moves the job to the “Dead” queue. You got that policy for free without writing any of it. Under Solid Queue, a job with no retry_on fails once and goes straight to the failed queue, so every job you migrate needs its retry behavior made explicit.
Retry-Parity Mapping
To reproduce Sidekiq’s behavior, add explicit Active Job declarations to ApplicationJob. The catch-all StandardError line below is the direct equivalent of Sidekiq’s default 25-retry policy; the specific retry_on and discard_on lines are where you do better than the implicit default by deciding which errors are worth retrying.
# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
# Don't retry job if the record was deleted
discard_on ActiveRecord::RecordNotFound
discard_on ActiveJob::DeserializationError
# Retry specific transient errors with tighter limits
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
# Catch-all: the direct equivalent of Sidekiq's implicit 25-retry default
retry_on StandardError, wait: :exponentially_longer, attempts: 25
end
| Sidekiq behavior | Solid Queue / Active Job equivalent |
|---|---|
| Implicit 25 retries, exponential backoff | retry_on StandardError, wait: :exponentially_longer, attempts: 25 |
sidekiq_options retry: 5 on a worker |
retry_on StandardError, attempts: 5 on that job |
sidekiq_options retry: false |
No retry_on (or discard_on the relevant error) |
| Job re-raises, hits Dead queue after retries | Job lands in solid_queue_failed_executions |
| Death handler / custom backoff | retry_on SomeError, wait: ->(executions) { ... } |
Per-job overrides work the same way Sidekiq’s per-worker options did: declare retry_on / discard_on on the individual job class to deviate from the ApplicationJob defaults.
For inspecting and re-running failed jobs after the cutover, use Mission Control - Jobs (covered in the Observability section below) in place of Sidekiq Web’s Dead tab.
Scheduling & Recurring Jobs: Cron Migration
Replace sidekiq-cron or sidekiq-scheduler with Solid Queue’s built-in config/recurring.yml. No extra gems needed - scheduling is handled natively with a simpler Fugit-based syntax.
From sidekiq-cron
Current setup (Sidekiq):
# config/schedule.yml
daily_summary:
cron: "0 9 * * *"
class: "DailySummaryJob"
queue: mailers
description: "Send daily summary emails"
cleanup_sessions:
cron: "0 */6 * * *"
class: "SessionCleanupJob"
queue: low_priority
process_subscriptions:
cron: "0 2 * * *"
class: "SubscriptionChargeJob"
queue: critical
args:
force: true
To Solid Queue recurring.yml
# config/recurring.yml
production:
daily_summary:
class: DailySummaryJob
schedule: every day at 9am
queue: mailers
# description: "Send daily summary emails" # Not supported, use comments
cleanup_sessions:
class: SessionCleanupJob
schedule: every 6 hours
queue: low_priority
process_subscriptions:
class: SubscriptionChargeJob
schedule: every day at 2am
queue: critical
args: [{ force: true }]
Cron Syntax Translation
sidekiq-cron uses standard cron:
0 9 * * * # Daily at 9am
*/15 * * * * # Every 15 minutes
0 */4 * * * # Every 4 hours
Solid Queue uses Fugit (more readable):
schedule: every day at 9am
schedule: every 15 minutes
schedule: every 4 hours
schedule: "0 9 * * *" # Can still use cron syntax
Production Migration Example
# config/recurring.yml
production:
# FinTech reconciliation (was 0 1 * * *)
daily_reconciliation:
class: TransactionReconciliationJob
schedule: every day at 1am
queue: critical
# Report generation (was 0 6 * * 1)
weekly_reports:
class: WeeklyReportJob
schedule: every monday at 6am
queue: default
# Cleanup old data (was 0 3 * * *)
cleanup_old_records:
class: DataCleanupJob
schedule: every day at 3am
queue: low_priority
# Sync with external API (was */30 * * * *)
api_sync:
class: ExternalApiSyncJob
schedule: every 30 minutes
queue: default
# Send digest emails (was 0 8 * * 1,3,5)
digest_emails:
class: DigestEmailJob
schedule: "0 8 * * 1,3,5" # Mon, Wed, Fri at 8am
queue: mailers
One Source of Truth During Cutover
Critical: Avoid double-enqueueing during migration.
Bad approach (causes duplicates):
# Day 1: Both systems enabled
# sidekiq-cron runs DailySummaryJob at 9am
# Solid Queue also runs DailySummaryJob at 9am
# Users get TWO summary emails
Good approach:
Deploy N (disable sidekiq-cron):
# config/initializers/sidekiq_cron.rb
unless ENV['ENABLE_SIDEKIQ_CRON'] == 'true'
# Don't load sidekiq-cron schedule
Rails.logger.info "Sidekiq-cron disabled"
end
Deploy N+1 (enable Solid Queue scheduler):
# config/recurring.yml is now active
# Scheduler starts on next deploy
Verification:
# Check Solid Queue scheduled jobs
SolidQueue::RecurringTask.all.each do |task|
puts "#{task.key}: #{task.schedule}"
end
Concurrency, Throttling & Uniqueness: The Gotchas
The one concurrency feature that doesn’t migrate cleanly is Sidekiq Enterprise unique jobs. Solid Queue has no built-in uniqueness, so every job that relied on unique_for needs an explicit replacement before you cut it over. (Per-queue thread and process tuning is covered in the setup guide; the migration-specific gotcha is uniqueness.)
Mapping Sidekiq Enterprise Unique Jobs
Sidekiq Enterprise gives you uniqueness with one option:
class UniqueJob
include Sidekiq::Worker
sidekiq_options unique_for: 10.minutes
def perform(user_id)
# Only one instance per user_id in 10 minutes
end
end
Solid Queue has no equivalent, so map each unique job to one of these:
Option 1: limits_concurrency (closest equivalent)
class ProcessUserJob < ApplicationJob
# Replaces unique_for: only one job per user runs at a time
limits_concurrency to: 1, key: -> (user_id) { "process_user_#{user_id}" }
def perform(user_id)
# Only one job per user at a time
end
end
This limits concurrent execution, not enqueueing. It prevents two jobs running at once but doesn’t deduplicate the queue the way unique_for does. For most “don’t double-process this resource” cases, that’s exactly what you want.
Option 2: Database-backed idempotency (for must-not-double-process work)
class ProcessPaymentJob < ApplicationJob
def perform(payment_id)
payment = Payment.lock.find(payment_id) # row lock
return if payment.processed? # Already done, no-op
process_payment(payment)
payment.update!(processed: true)
end
end
Idempotency in the job body is the most robust replacement: even if the job runs twice, the second run is a no-op. Prefer this for payments, charges, and anything where a duplicate has real consequences.
This is the one area where Sidekiq Enterprise is more mature than Solid Queue. Budget time for it during the inventory phase and treat these as High-risk jobs to migrate last.
Observability & Dashboards
Mount Mission Control - Jobs as your replacement for Sidekiq Web. Swap mount Sidekiq::Web, at: '/sidekiq' for mount MissionControl::Jobs::Engine, at: '/jobs' behind the same admin authentication, and you keep active, failed, scheduled, and recurring job views with retry and discard actions. During migration it’s the one place to confirm jobs are landing on Solid Queue, watch the failed queue as you flip retry semantics, and verify recurring jobs fire after the deploy-N / deploy-N+1 cutover. For installation, securing the dashboard in production, the console API, and alerting, see the dedicated post on monitoring Solid Queue with Mission Control.
Rolling Deploys & Zero-Downtime Cutovers
Deploy with zero downtime by sending TERM to the Solid Queue supervisor - it waits for running jobs to finish before exiting. This is simpler than Sidekiq’s two-signal (TSTP then TERM) approach.
Current Sidekiq Deploy Process
Typical flow:
# 1. Quiet Sidekiq (stop accepting new jobs)
kill -TSTP $(cat tmp/pids/sidekiq.pid)
# 2. Wait for current jobs to finish (with timeout)
timeout 60 bash -c 'while kill -0 $(cat tmp/pids/sidekiq.pid) 2>/dev/null; do sleep 1; done'
# 3. Deploy new code
git pull
bundle install
# ... restart app
# 4. Start new Sidekiq
bundle exec sidekiq -d -C config/sidekiq.yml
# 5. Terminate old Sidekiq (if still running)
kill -TERM $(cat tmp/pids/sidekiq.pid.oldbin)
Solid Queue Deploy Process
Simpler flow:
# 1. Send TERM to supervisor (graceful shutdown)
kill -TERM $(cat tmp/pids/solid_queue.pid)
# Wait for shutdown (respects shutdown_timeout)
# Default: 60 seconds
# 2. Deploy new code
git pull
bundle install
# 3. Start new Solid Queue
bin/jobs
Configure shutdown timeout:
# config/queue.yml
production:
workers:
- queues: default
threads: 5
shutdown_timeout: 60 # Wait 60s for jobs to finish
Blue/Green Migration Strategy
Run both systems during transition:
Week 1-2: Sidekiq primary, Solid Queue testing
# Most jobs on Sidekiq
config.active_job.queue_adapter = :sidekiq
# Test jobs on Solid Queue
class TestJob < ApplicationJob
self.queue_adapter = :solid_queue
end
Week 3: Split traffic
# Move 50% of jobs to Solid Queue
class ApplicationJob < ActiveJob::Base
# Default to Solid Queue
end
# Keep critical jobs on Sidekiq temporarily
class PaymentJob < ApplicationJob
self.queue_adapter = :sidekiq
end
Week 4: Solid Queue primary
# All jobs on Solid Queue
config.active_job.queue_adapter = :solid_queue
# Disable sidekiq-cron
# Keep Sidekiq running to drain old jobs
Week 5+: Decommission Sidekiq
# Verify no jobs in Sidekiq
Sidekiq::Queue.all.each do |queue|
puts "#{queue.name}: #{queue.size}"
end
# Verify no scheduled jobs
puts "Scheduled: #{Sidekiq::ScheduledSet.new.size}"
puts "Retries: #{Sidekiq::RetrySet.new.size}"
puts "Dead: #{Sidekiq::DeadSet.new.size}"
# All zeros? Safe to shut down Sidekiq
kill -TERM $(cat tmp/pids/sidekiq.pid)
# Remove from systemd/Docker/Procfile
Puma Plugin Caveat
Don’t use Puma plugin in production for Solid Queue:
# config/puma.rb
# DON'T DO THIS IN PRODUCTION
plugin :solid_queue # Doesn't support phased restarts
Why: Puma’s phased restart doesn’t gracefully shut down Solid Queue workers.
Better: Run bin/jobs as separate service (systemd, Docker, or Kamal).
Step-by-Step Migration Runbook
Copy-paste this into your migration plan.
Preparation
- Run job inventory script
- Document all Sidekiq queues and concurrency settings
- List all sidekiq-cron jobs
- Identify jobs with unique/rate-limit requirements
- Review retry and error handling logic
- Plan rollback strategy
- Set up staging environment for testing
Phase 1: Install
# Install Solid Queue
bundle add solid_queue
bin/rails solid_queue:install
# Configure separate database (optional but recommended)
# Edit config/database.yml
# Create database and run migrations
RAILS_ENV=production bin/rails db:create:queue
RAILS_ENV=production bin/rails db:migrate:queue
# Configure worker topology
# Edit config/queue.yml
# Start Solid Queue (separate process)
bin/jobs
Verify:
# Check Solid Queue is running
ps aux | grep solid_queue
# Check database
rails console
SolidQueue::Job.count # Should be 0
Phase 2: Migrate Low-Risk Jobs
Pick 2-3 non-critical jobs:
# app/jobs/cleanup_job.rb
class CleanupJob < ApplicationJob
self.queue_adapter = :solid_queue # Add this line
queue_as :low_priority
def perform
# Existing logic
end
end
Deploy and verify:
# Enqueue test job
CleanupJob.perform_later
# Check Mission Control
# Visit /jobs and verify job appears
Monitor for a week or two:
- Check error rates
- Verify jobs complete successfully
- Compare performance with Sidekiq
Phase 3: Align Retry Semantics
Add explicit retry configuration:
# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
# Match Sidekiq behavior
retry_on StandardError,
wait: :exponentially_longer,
attempts: 25
discard_on ActiveJob::DeserializationError
discard_on ActiveRecord::RecordNotFound
# Add logging and error reporting
rescue_from(StandardError) do |exception|
Rails.error.report(exception, handled: true, context: {
job_class: self.class.name,
job_id: job_id,
arguments: arguments
})
raise
end
end
Test failure scenarios:
# Create job that fails
class TestFailureJob < ApplicationJob
self.queue_adapter = :solid_queue
def perform
raise "Test error"
end
end
TestFailureJob.perform_later
# Check Mission Control /jobs/failed
# Verify retry behavior
# Verify error reporting
Phase 4: Migrate Recurring Jobs
Create config/recurring.yml:
production:
daily_summary:
class: DailySummaryJob
schedule: every day at 9am
queue: mailers
cleanup_sessions:
class: SessionCleanupJob
schedule: every 6 hours
queue: low_priority
Deploy with sidekiq-cron disabled:
# config/initializers/sidekiq_cron.rb
if ENV['ENABLE_SIDEKIQ_CRON'] == 'true'
# Load schedule
else
Rails.logger.info "Sidekiq-cron disabled, using Solid Queue recurring jobs"
end
Verify recurring jobs:
SolidQueue::RecurringTask.all.each do |task|
puts "#{task.key}: next run at #{task.next_time}"
end
Monitor for a week:
- Verify jobs run at correct times
- Check for duplicates (should be none)
- Verify no missed executions
Phase 5: Match Throughput
Tune config/queue.yml to match Sidekiq capacity:
production:
workers:
# Calculate: Sidekiq concurrency = 25
# Distribute across Solid Queue workers
- queues: critical
threads: 5
processes: 2 # 10 workers
- queues: default
threads: 5
processes: 2 # 10 workers
- queues: [mailers, low_priority]
threads: 5
processes: 1 # 5 workers
# Total: 25 concurrent jobs (matches Sidekiq)
Load test:
# Enqueue 1000 jobs
1000.times do |i|
SomeJob.perform_later(i)
end
# Monitor processing rate
# Compare with Sidekiq baseline
Phase 6: Flip Global Adapter
# config/application.rb
config.active_job.queue_adapter = :solid_queue # Change from :sidekiq
Keep override for critical jobs (if needed):
class CriticalPaymentJob < ApplicationJob
self.queue_adapter = :sidekiq # Temporary, migrate later
end
Deploy and monitor closely:
- Watch error rates
- Monitor queue depths
- Check job latency
- Verify no jobs stuck
Phase 7: Decommission Sidekiq
After 1-2 weeks of stable Solid Queue operation:
# 1. Verify Sidekiq queues empty
Sidekiq::Queue.all.map(&:size).sum # Should be 0
# 2. Verify no scheduled jobs
Sidekiq::ScheduledSet.new.size +
Sidekiq::RetrySet.new.size +
Sidekiq::DeadSet.new.size # Should be 0
# 3. Stop Sidekiq
systemctl stop sidekiq
# or
kill -TERM $(cat tmp/pids/sidekiq.pid)
# 4. Remove from deploy config
# - Remove from Procfile/systemd
# - Remove sidekiq.yml
# - Remove config/initializers/sidekiq.rb
# 5. Remove gems
# Gemfile
# gem 'sidekiq'
# gem 'sidekiq-cron'
bundle install
Archive Sidekiq metrics and configuration for reference.
Rollback Plan: Practice It Once
You need a tested rollback plan. Practice before migration.
Immediate Rollback
Scenario: Solid Queue is causing issues, need to revert NOW.
# 1. Revert adapter change
git revert <commit-hash> # Revert queue adapter change
# 2. Deploy immediately
git push
# Trigger deploy
# 3. Restart Sidekiq (if stopped)
systemctl start sidekiq
# or
bundle exec sidekiq -d -C config/sidekiq.yml
# 4. Keep Solid Queue running
# Let it drain already-enqueued jobs
# Or explicitly fail and re-enqueue later
Re-enqueue failed Solid Queue jobs to Sidekiq:
# In Rails console
SolidQueue::Job.failed.find_each do |job|
# Extract job info
job_class = job.class_name.constantize
arguments = job.arguments
# Re-enqueue to Sidekiq
job_class.set(queue: job.queue_name).perform_later(*arguments)
end
Graceful Rollback
Scenario: Issues discovered, want controlled rollback.
Phase 1:
# Move jobs back to Sidekiq one by one
class SomeJob < ApplicationJob
self.queue_adapter = :sidekiq # Add override
end
# Deploy incrementally
Phase 2:
# Revert global adapter
config.active_job.queue_adapter = :sidekiq
# Re-enable sidekiq-cron
ENV['ENABLE_SIDEKIQ_CRON'] = 'true'
# Stop Solid Queue
kill -TERM $(cat tmp/pids/solid_queue.pid)
Practice Rollback in Staging
Before production migration:
# 1. Set up staging with both systems
# 2. Migrate to Solid Queue
# 3. Run production-like load
# 4. Practice rollback
# 5. Verify all jobs processed correctly
Measure rollback time. Should be quick and reliable.
Testing & CI Safety Nets
Automated tests to catch migration issues.
Active Job Test Helpers
# spec/jobs/my_job_spec.rb
require 'rails_helper'
RSpec.describe MyJob, type: :job do
describe '#perform' do
it 'enqueues job to correct queue' do
MyJob.perform_later(123)
expect(MyJob).to have_been_enqueued.with(123)
expect(MyJob).to have_been_enqueued.on_queue('default')
end
it 'schedules job for future' do
MyJob.set(wait: 1.hour).perform_later(123)
expect(MyJob).to have_been_enqueued.at(1.hour.from_now).with(123)
end
it 'retries on errors' do
allow_any_instance_of(MyJob).to receive(:perform).and_raise(StandardError)
MyJob.perform_later(123)
perform_enqueued_jobs
# Should retry based on retry_on configuration
expect(MyJob).to have_been_enqueued.at_least(:twice)
end
end
end
Migration-Specific Tests
# spec/jobs/migration_spec.rb
require 'rails_helper'
RSpec.describe 'Job migration to Solid Queue' do
before do
# Ensure using Solid Queue adapter
ActiveJob::Base.queue_adapter = :solid_queue
end
it 'processes jobs successfully' do
expect {
MyJob.perform_later(123)
perform_enqueued_jobs
}.not_to raise_error
end
it 'retries failed jobs correctly' do
allow_any_instance_of(MyJob).to receive(:perform).and_raise(StandardError).once
allow_any_instance_of(MyJob).to receive(:perform).and_call_original
MyJob.perform_later(123)
perform_enqueued_jobs
# Should succeed on retry
expect(MyJob).to have_been_performed
end
it 'respects concurrency limits' do
# Test job-level concurrency controls
jobs = 5.times.map { ConcurrencyLimitedJob.perform_later }
# Only configured number should run simultaneously
# Implementation depends on your concurrency setup
end
end
Canary Job
Add a recurring canary to verify scheduler health:
# config/recurring.yml
production:
canary_health_check:
class: CanaryJob
schedule: every 5 minutes
queue: default
# app/jobs/canary_job.rb
class CanaryJob < ApplicationJob
queue_as :default
def perform
# Record successful execution
Rails.cache.write(
'canary_last_run',
Time.current,
expires_in: 10.minutes
)
# Send metric
ActiveSupport::Notifications.instrument(
'canary.success',
timestamp: Time.current
)
end
end
Monitor canary in production:
# Health check endpoint
def jobs_health
last_canary = Rails.cache.read('canary_last_run')
if last_canary && last_canary > 10.minutes.ago
render json: { status: 'ok', last_canary: last_canary }
else
render json: { status: 'unhealthy', last_canary: last_canary }, status: 503
end
end
Alert if canary hasn’t run in > 10 minutes.
Common Pitfalls & How to Avoid Them
Avoid these six specific mistakes that cause failed Sidekiq to Solid Queue migrations. Each includes the exact fix.
1. Assuming Sidekiq Retry Semantics Carry Over
Problem:
# This job worked in Sidekiq (automatic retries)
class ImportantJob < ApplicationJob
def perform
ExternalAPI.call # Sometimes fails
end
end
# In Solid Queue: fails once, goes to failed queue, never retries
Solution: Explicit retry configuration
class ImportantJob < ApplicationJob
retry_on StandardError, wait: :exponentially_longer, attempts: 25
def perform
ExternalAPI.call
end
end
2. Queue Weighting Mental Model
Problem:
# Sidekiq mental model: weights
# [critical, 5], [default, 1]
# = Critical gets ~83% of resources
# Solid Queue config attempt (WRONG):
workers:
- queues: [critical, default] # Both processed equally
threads: 5
Solution: Separate workers or queue order
# Option 1: Separate workers
workers:
- queues: critical
threads: 8 # 80% of resources
- queues: default
threads: 2 # 20% of resources
# Option 2: Queue order (processes critical first)
workers:
- queues: [critical, default]
threads: 10
3. Cron Duplication (Double Enqueues)
Problem:
# Both systems running same cron job
# sidekiq-cron: DailySummaryJob every day at 9am
# Solid Queue recurring.yml: DailySummaryJob every day at 9am
# Result: Users get 2 emails
Solution: Single source of truth
# Deploy N: Disable sidekiq-cron
if ENV['ENABLE_SIDEKIQ_CRON'] != 'true'
Rails.logger.info "Sidekiq-cron disabled"
# Don't load schedule
end
# Deploy N+1: Enable Solid Queue recurring jobs
# config/recurring.yml now active
4. Overusing Concurrency Controls
Problem:
# Every job has concurrency control
class Job1 < ApplicationJob
limits_concurrency to: 5, key: -> { "job1" }
end
class Job2 < ApplicationJob
limits_concurrency to: 10, key: -> { "job2" }
end
# Complex, hard to reason about, debugging nightmare
Solution: Use topology first
# Simple and clear
workers:
- queues: job1_queue
threads: 5
- queues: job2_queue
threads: 10
Only use concurrency controls for:
- Per-resource limits (e.g., one export per account)
- Protecting external APIs
- Preventing race conditions
5. Not Testing Rollback
Problem: Production issues, attempt rollback, discover:
- Rollback process unclear
- Jobs lost during transition
- Sidekiq configuration deleted
- Team doesn’t know how to re-enqueue jobs
Solution: Practice rollback in staging
- Document exact steps
- Test re-enqueueing failed jobs
- Keep Sidekiq config until fully decommissioned
- Time the rollback
6. Connection Pool Exhaustion
Problem:
# Solid Queue workers
workers:
- queues: default
threads: 25
processes: 4
# Total: 100 concurrent jobs
# But database pool:
production:
pool: 5 # Not enough!
Each thread needs a DB connection. 100 threads needs pool of at least 100. For more on PostgreSQL tuning, see Database Optimization in Rails.
Solution:
# config/database.yml
production:
queue:
pool: <%= ENV.fetch("SOLID_QUEUE_POOL_SIZE", 110) %>
Production Deployment Configurations
Copy-paste configs for different deployment methods.
Systemd Service
# /etc/systemd/system/solid-queue.service
[Unit]
Description=Solid Queue Worker
After=network.target postgresql.service
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp/current
Environment=RAILS_ENV=production
Environment=SOLID_QUEUE_POOL_SIZE=50
ExecStart=/usr/local/bin/bundle exec bin/jobs
ExecReload=/bin/kill -TERM $MAINPID
# Graceful shutdown
KillSignal=SIGTERM
TimeoutStopSec=60
KillMode=mixed
# Restart on failure
Restart=on-failure
RestartSec=5
# Logging
StandardOutput=append:/var/log/solid-queue/stdout.log
StandardError=append:/var/log/solid-queue/stderr.log
[Install]
WantedBy=multi-user.target
# Enable and start
sudo systemctl enable solid-queue
sudo systemctl start solid-queue
# Check status
sudo systemctl status solid-queue
# View logs
sudo journalctl -u solid-queue -f
# Restart (graceful)
sudo systemctl reload solid-queue
# Stop
sudo systemctl stop solid-queue
Docker Compose
# docker-compose.yml
version: '3.8'
services:
web:
image: myapp:latest
command: bundle exec puma
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp_production
- QUEUE_DATABASE_URL=postgresql://postgres:password@db:5432/myapp_queue_production
- RAILS_ENV=production
depends_on:
- db
jobs:
image: myapp:latest
command: bundle exec bin/jobs
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp_production
- QUEUE_DATABASE_URL=postgresql://postgres:password@db:5432/myapp_queue_production
- RAILS_ENV=production
- SOLID_QUEUE_POOL_SIZE=50
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=password
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
Kamal Configuration
# .kamal/deploy.yml
service: myapp
image: username/myapp
servers:
web:
hosts:
- 192.168.1.1
options:
network: "private"
jobs:
cmd: bin/jobs
hosts:
- 192.168.1.1
options:
network: "private"
env:
clear:
SOLID_QUEUE_POOL_SIZE: 50
registry:
username: username
password:
- KAMAL_REGISTRY_PASSWORD
env:
secret:
- DATABASE_URL
- QUEUE_DATABASE_URL
- SECRET_KEY_BASE
accessories:
postgres:
image: postgres:16
host: 192.168.1.1
port: 5432
env:
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
options:
network: "private"
# Deploy
kamal deploy
# Restart jobs only
kamal app boot --roles jobs
# View logs
kamal app logs --roles jobs
# SSH to jobs container
kamal app exec --roles jobs sh
Procfile (Heroku/Render)
# Procfile
web: bundle exec puma -C config/puma.rb
jobs: bundle exec bin/jobs
Heroku:
# Scale jobs
heroku ps:scale jobs=2
# View logs
heroku logs --ps jobs --tail
# Restart jobs
heroku ps:restart jobs
Limitations and Trade-offs
Solid Queue trades raw speed for operational simplicity. Here are the concrete trade-offs to plan for during a migration.
Higher job start latency: Sidekiq starts jobs in 5-10ms via Redis pub/sub; Solid Queue’s polling model means jobs start in 100ms to a few seconds, depending on your polling_interval. See the latency section of the setup guide for the full breakdown.
- Impact: Acceptable for almost all background work. Jobs aren’t user-facing.
- Mitigation: Lower
polling_intervalon latency-sensitive queues, or keep those queues on Sidekiq.
Lower peak throughput: Sidekiq sustains far higher throughput than a database-backed queue. For volumes under roughly 1,000 jobs/minute the difference doesn’t show up in practice.
- Impact: Still well above typical requirements for most apps.
- Mitigation: Add worker threads and processes, or isolate firehose queues to Sidekiq.
Feature loss: No built-in unique jobs
- Impact: Any job relying on Sidekiq Enterprise uniqueness needs manual deduplication.
- Mitigation: Database-backed idempotency keys or
limits_concurrency.
What You Gain
Simplicity: One less service to manage, monitor, upgrade
Reliability: Fewer moving parts = fewer failure modes
Developer experience: Better Rails integration, nicer dashboard
Reduced operational overhead: Single database to maintain
Should you make the switch?
Migrating from Sidekiq to Solid Queue is straightforward if you:
- Plan incrementally - Per-job migration, not big bang
- Match retry semantics - Explicit Active Job configuration
- Test rollback - Practice before production
- Monitor closely - First 2 weeks are critical
- Accept trade-offs - Slightly higher latency for simpler ops
You should migrate if:
- Job volume under ~1,000 jobs/minute
- Job start latency of 100ms or more is fine
- Team values operational simplicity
- Using PostgreSQL already
Stick with Sidekiq if:
- You process millions of jobs per day
- You need sub-100ms job start latency
- Heavily using Pro/Enterprise features (batches, unique jobs)
- Already have mature Sidekiq setup working well
For most Rails applications, Solid Queue’s simplicity outweighs the small latency increase. The migration is less scary than it seems. Take it one job at a time, test thoroughly, and keep a rollback plan ready.
Need help migrating from Sidekiq? I help teams with zero-downtime job backend migrations, Rails architecture, and performance tuning. If you’re planning a cutover, reach out at nikita.sinenko at gmail.com.
Further Reading
- Solid Queue in Rails 8: Install, Migrate, and Deploy - the setup guide for the comparison, latency, and configuration details this runbook links to
- Monitor Solid Queue with Mission Control in Rails 8 - dashboard install, securing it, and alerting
- Solid Cache in Rails 8: Database-Backed Caching
- How to Deploy Rails 8 Apps with Kamal to a VPS
- Rails 8 Key Features
- Database Optimization Techniques in Rails
- Solid Queue GitHub Repository
- Active Job Basics - Rails Guides
- Mission Control - Jobs
- Sidekiq Error Handling