rails background-jobs migration

Sidekiq to Solid Queue Migration: Rails Runbook

37 min read

Migrate Sidekiq to Solid Queue in Rails 8 with per-job adapter rollout, retry mapping, recurring job cutover, and tested rollback.

Sidekiq to Solid Queue migration diagram showing incremental per-job rollout with zero downtime in Rails

Migrate Sidekiq to Solid Queue without downtime by running both backends at once: keep the global adapter on Sidekiq, install Solid Queue alongside it, then move one job class at a time with self.queue_adapter = :solid_queue. Verify each job, map retries explicitly, and keep a tested rollback.

Migrating a background job backend in a live app is nerve-wracking: get it wrong and you're double-processing payments or silently dropping jobs. This is a runbook, not a postmortem of one named migration, so it deliberately avoids pretending the happy path is proof. Inventory first, one job at a time, both systems side by side, and no cutover until staging has exercised the rollback.

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.

How to Migrate from Sidekiq to Solid Queue

To migrate from Sidekiq to Solid Queue, inventory your existing jobs, install Solid Queue alongside Sidekiq, configure queue routing to match your current topology, make retry semantics explicit, convert recurring jobs to config/recurring.yml, run both backends side by side, then cut over and decommission Sidekiq once every job is verified.

Each step below expands into a copy-paste runbook with a rollback at every phase. The whole sequence takes two to four weeks of calendar time, not engineering hours - most of it is watching migrated jobs run under real traffic before you touch the high-risk ones.

Before You Start: Inventory & Risk Map

Start by auditing every Sidekiq job, queue, retry policy, and scheduling source before writing any migration code. Skipping the inventory is the most common way these migrations fail: the surprise during cutover is always a job nobody remembered was there.

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::Worker classes - 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, and never flip everything in one deploy. The whole plan rests on one Active Job feature: a single job class can override the global adapter. You keep the app-wide default on Sidekiq, install Solid Queue alongside it (the Phase 1-7 runbook below has the install commands), and move jobs across one class at a time. If a migrated job misbehaves, that one class flips back, not your entire queue system.

# app/jobs/low_risk_job.rb
class LowRiskJob < ApplicationJob
  self.queue_adapter = :solid_queue  # this class only
  queue_as :default

  def perform(user_id)
    # Job logic
  end
end
# config/application.rb
config.active_job.queue_adapter = :sidekiq  # still the default for everything else

Now LowRiskJob.perform_later(123) runs on Solid Queue while everything else stays on Sidekiq. Native Sidekiq::Worker classes (not Active Job subclasses) have no adapter to override, so they keep running on Sidekiq until you rewrite them as ApplicationJob subclasses.

Start with jobs that are safe to get wrong: non-critical, low-volume, safely retryable, and well-monitored. Leave payment processing, critical notifications, high-volume queues, and anything with complex retry logic until last, after the pattern is proven. Everything between here and the runbook covers the parts that need a real decision rather than a copy-paste: queue routing, retry parity, cron cutover, and uniqueness.

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 approximate Sidekiq's behavior, add explicit Active Job declarations to ApplicationJob. The catch-all StandardError line below is the closest Active Job approximation of Sidekiq's default 25-retry policy, not an exact match: Active Job's :polynomially_longer backoff curve is not identical to Sidekiq's, so the retry timing differs. 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: approximates Sidekiq's implicit 25-retry default (backoff curve differs)
  retry_on StandardError, wait: :polynomially_longer, attempts: 25
end
Sidekiq behavior Solid Queue / Active Job equivalent
Implicit 25 retries with automatic backoff retry_on StandardError, wait: :polynomially_longer, attempts: 25 (approximate; timing differs)
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. For the full scheduling reference beyond this migration, see the guide to Solid Queue recurring and cron jobs.

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

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 reliable 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, 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)
# shutdown_timeout defaults to 5s; this runbook raises it to 60s below

# 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

Puma Plugin Caveat

Don't use Puma plugin under real load 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: :polynomially_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

Take the topology from the queue-mapping step, which came to 22 concurrent jobs, and tune it up to match Sidekiq's concurrency: 25. Only two workers change; critical stays at 5 threads across 2 processes (10 workers):

production:
  workers:
    # critical: unchanged from the mapping topology (5 threads x 2 = 10 workers)

    - queues: default
      threads: 5      # was 3; now 10 workers instead of 6
      processes: 2

    - queues: [mailers, low_priority]
      threads: 5      # combined into one 5-worker pool
      processes: 1

    # Total across all queues: 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 migration:

# 1. Set up staging with both systems
# 2. Migrate to Solid Queue
# 3. Run realistic load
# 4. Practice rollback
# 5. Verify all jobs processed correctly

Time the rollback while you're at it. If reverting takes longer than a normal deploy, fix the runbook before you touch the live system. The usual failure is not the adapter flip; it is the forgotten side process: a scheduler still enabled, a Sidekiq service not restarted by the deploy, or a dashboard still pointing at the queue you just moved away from.

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 under real traffic:

# 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: :polynomially_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: Live 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) %>

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

# config/deploy.yml
service: myapp

image: username/myapp

servers:
  web:
    - 192.168.1.1

  jobs:
    hosts:
      - 192.168.1.1
    cmd: bin/jobs
    env:
      clear:
        SOLID_QUEUE_POOL_SIZE: 50

proxy:
  ssl: true
  host: app.example.com

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: "127.0.0.1:5432:5432"
    env:
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
# 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. For background work that's acceptable - jobs aren't user-facing. Lower polling_interval on latency-sensitive queues, or keep those queues on Sidekiq. See the latency section of the setup guide for the full breakdown.

Lower peak throughput. Sidekiq sustains far higher throughput than a database-backed queue, but for volumes under roughly 1,000 jobs/minute the difference doesn't show up in practice. If one queue is a firehose, add worker threads and processes, or leave that queue on Sidekiq.

No built-in unique jobs. Any job that relied on Sidekiq Enterprise uniqueness needs manual deduplication: database-backed idempotency keys or limits_concurrency, both covered earlier in this post.

What You Gain

The other side of the ledger is mostly about deleting things. Redis disappears from your stack, which means one less service to provision, monitor, upgrade, and get paged about. Your jobs live in the same PostgreSQL database you already back up. And Mission Control integrates with Rails more tightly than Sidekiq Web did.

Should you make the switch?

For many Rails apps, yes. The latency cost is invisible when the jobs are emails, imports, reports, and cleanup work. The migration itself is mostly discipline: a small set of code changes, followed by enough time watching dashboards to prove the new queue behaves like the old one.

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

If you do migrate, two things in this runbook deserve most of your attention: making Sidekiq's implicit retries explicit, and cutting cron over in two deploys so nothing double-enqueues. Get those right and the rest is bookkeeping.


Before touching the adapter, put the retry map, cron inventory, rollback command, and dashboard checks in one document. That artifact matters more than whether the first migrated job is large or small.

Further Reading