Claude Code Plugins

Community-maintained marketplace

Feedback

Sidekiq & Async Patterns

@Kaakati/rails-enterprise-dev
1
0

Complete guide to background job processing with Sidekiq in Ruby on Rails. Use this skill when: (1) Creating background jobs, (2) Configuring queues and workers, (3) Implementing retry logic and error handling, (4) Designing idempotent jobs, (5) Setting up scheduled/recurring jobs, (6) Optimizing job performance.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name Sidekiq & Async Patterns
description Complete guide to background job processing with Sidekiq in Ruby on Rails. Use this skill when: (1) Creating background jobs, (2) Configuring queues and workers, (3) Implementing retry logic and error handling, (4) Designing idempotent jobs, (5) Setting up scheduled/recurring jobs, (6) Optimizing job performance.

Sidekiq & Async Patterns Skill

This skill provides comprehensive guidance for implementing background jobs with Sidekiq in Rails applications.

When to Use This Skill

  • Creating new background jobs
  • Configuring Sidekiq queues
  • Implementing retry strategies
  • Designing idempotent operations
  • Setting up scheduled jobs
  • Handling job failures
  • Optimizing async processing

External Documentation

Official Wiki: https://github.com/sidekiq/sidekiq/wiki

# Always check the official wiki for latest patterns
# Key wiki pages:
# - Best Practices: https://github.com/sidekiq/sidekiq/wiki/Best-Practices
# - Error Handling: https://github.com/sidekiq/sidekiq/wiki/Error-Handling
# - Scheduled Jobs: https://github.com/sidekiq/sidekiq/wiki/Scheduled-Jobs

Pre-Work Inspection

# Check existing jobs
ls app/jobs/ app/sidekiq/ app/workers/ 2>/dev/null

# Check job naming conventions
head -30 $(find app/jobs -name '*.rb' | head -1) 2>/dev/null

# Check Sidekiq configuration
cat config/sidekiq.yml 2>/dev/null
cat config/initializers/sidekiq.rb 2>/dev/null

# Check queue configuration
grep -r 'queue_as\|sidekiq_options' app/jobs/ --include='*.rb' | head -10

# Check scheduled jobs
cat config/schedule.yml 2>/dev/null
cat config/recurring.yml 2>/dev/null

Core Principles

1. Idempotency (CRITICAL)

Jobs MUST be idempotent - running the same job multiple times produces the same result.

# WRONG - Not idempotent (sends duplicate emails)
class SendWelcomeEmailJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

# CORRECT - Idempotent (checks before sending)
class SendWelcomeEmailJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    return if user.welcome_email_sent_at.present?
    
    UserMailer.welcome(user).deliver_now
    user.update!(welcome_email_sent_at: Time.current)
  end
end

2. Small Payloads

Pass IDs, not objects - objects serialize and can become stale.

# WRONG - Object serialization
NotificationJob.perform_later(@user)  # Serializes entire user object

# CORRECT - Pass ID
NotificationJob.perform_later(@user.id)

3. Fail Fast

Find records early - fail immediately if data doesn't exist.

class ProcessTaskJob < ApplicationJob
  def perform(task_id)
    task = Task.find(task_id)  # Raises if not found
    # ... process task
  end
end

Job Structure Template

# app/jobs/task_notification_job.rb
class TaskNotificationJob < ApplicationJob
  queue_as :notifications
  
  # Retry configuration
  retry_on ActiveRecord::RecordNotFound, wait: 5.seconds, attempts: 3
  retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
  discard_on ActiveJob::DeserializationError

  def perform(task_id, notification_type = :created)
    task = Task.find(task_id)
    
    case notification_type.to_sym
    when :created
      send_creation_notification(task)
    when :completed
      send_completion_notification(task)
    end
  end

  private

  def send_creation_notification(task)
    return unless task.recipient.phone.present?
    return if task.notification_sent_at.present?  # Idempotency check
    
    SmsService.send(
      to: task.recipient.phone,
      message: I18n.t('sms.task_created', tracking: task.tracking_number)
    )
    
    task.update!(notification_sent_at: Time.current)
  end

  def send_completion_notification(task)
    # Implementation
  end
end

Sidekiq Configuration

config/sidekiq.yml

---
:concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %>
:timeout: 25
:max_retries: 5

:queues:
  - [critical, 6]      # Highest priority
  - [default, 5]
  - [notifications, 4]
  - [integrations, 3]  # External API calls
  - [bulk, 2]          # Batch processing
  - [scheduled, 1]
  - [low, 1]           # Lowest priority

production:
  :concurrency: 20

development:
  :concurrency: 5

config/initializers/sidekiq.rb

Sidekiq.configure_server do |config|
  config.redis = {
    url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
    network_timeout: 5,
    pool_timeout: 5
  }

  # Death handler - job exhausted all retries
  config.death_handlers << ->(job, ex) do
    Rails.logger.error("Job #{job['class']} died: #{ex.message}")
    Sentry.capture_exception(ex, extra: { job: job })
  end
end

Sidekiq.configure_client do |config|
  config.redis = {
    url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
    network_timeout: 5,
    pool_timeout: 5
  }
end

# Recommended: strict argument mode
Sidekiq.strict_args!

Queue Strategy

Priority Guidelines

Queue Priority Use For
critical 6 Payment processing, time-sensitive operations
default 5 Standard operations
notifications 4 User notifications (SMS, email, push)
integrations 3 External API calls (may be slow)
bulk 2 Batch processing, imports
scheduled 1 Scheduled/recurring tasks
low 1 Non-urgent, can wait

Setting Queue in Jobs

class PaymentProcessingJob < ApplicationJob
  queue_as :critical
  
  def perform(payment_id)
    # Critical payment processing
  end
end

class SendNewsletterJob < ApplicationJob
  queue_as :low
  
  def perform(user_ids)
    # Low priority newsletter
  end
end

# Dynamic queue selection
class FlexibleJob < ApplicationJob
  queue_as do
    if self.arguments.first.priority == 'high'
      :critical
    else
      :default
    end
  end
end

Retry Strategies

Built-in Retry Configuration

class ExternalApiJob < ApplicationJob
  # Retry specific exceptions
  retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 3
  retry_on Faraday::TimeoutError, wait: :polynomially_longer, attempts: 5
  
  # Exponential backoff: 3s, 18s, 83s, 258s, 627s
  retry_on ApiRateLimitError, wait: :polynomially_longer, attempts: 5
  
  # Custom wait calculation
  retry_on CustomError, wait: ->(executions) { executions * 10.seconds }
  
  # Don't retry these - discard instead
  discard_on ActiveJob::DeserializationError
  discard_on UnrecoverableError
  
  def perform(task_id)
    # Implementation
  end
end

Manual Retry Control

class ManualRetryJob < ApplicationJob
  def perform(task_id)
    task = Task.find(task_id)
    
    begin
      external_service.process(task)
    rescue RateLimitError => e
      # Re-enqueue with delay
      self.class.set(wait: 1.minute).perform_later(task_id)
    rescue PermanentError => e
      # Don't retry, log and notify
      Rails.logger.error("Permanent failure: #{e.message}")
      Sentry.capture_exception(e)
    end
  end
end

Common Job Patterns

Batch Processing

class BulkUpdateJob < ApplicationJob
  queue_as :bulk

  def perform(record_ids, attributes)
    Model.where(id: record_ids).find_each(batch_size: 100) do |record|
      record.update!(attributes)
    rescue ActiveRecord::RecordInvalid => e
      Rails.logger.error("Failed to update #{record.id}: #{e.message}")
      # Continue with next record
    end
  end
end

Progress Tracking

class ImportJob < ApplicationJob
  queue_as :bulk

  def perform(import_id)
    import = Import.find(import_id)
    import.update!(status: 'processing', started_at: Time.current)

    total = import.rows.count
    processed = 0
    errors = []

    import.rows.each do |row|
      begin
        process_row(row, import)
        processed += 1
      rescue StandardError => e
        errors << { row: row[:number], error: e.message }
      end

      # Update progress every 10 rows
      if processed % 10 == 0
        import.update!(
          progress: (processed.to_f / total * 100).round(1),
          processed_count: processed
        )
      end
    end

    import.update!(
      status: errors.empty? ? 'completed' : 'completed_with_errors',
      completed_at: Time.current,
      error_details: errors
    )
  end
end

External API Calls

class SyncExternalDataJob < ApplicationJob
  queue_as :integrations

  retry_on Faraday::TimeoutError, wait: 1.minute, attempts: 3
  retry_on Faraday::ConnectionFailed, wait: 5.minutes, attempts: 3
  discard_on ExternalApi::ClientError  # 4xx errors - don't retry

  def perform(resource_id)
    resource = Resource.find(resource_id)
    
    data = ExternalApi.fetch(resource.external_id)
    
    resource.update!(
      external_data: data,
      synced_at: Time.current
    )
  end
end

Scheduled Jobs (with sidekiq-scheduler)

# config/recurring.yml
daily_cleanup:
  cron: '0 2 * * *'  # 2 AM daily
  class: DailyCleanupJob
  queue: scheduled

hourly_sync:
  cron: '0 * * * *'  # Every hour
  class: HourlySyncJob
  queue: integrations

weekly_report:
  cron: '0 9 * * 1'  # Monday 9 AM
  class: WeeklyReportJob
  queue: low

Unique Jobs (with sidekiq-unique-jobs)

class UniqueProcessingJob < ApplicationJob
  queue_as :default

  # Prevent duplicate jobs for same arguments
  sidekiq_options lock: :until_executed,
                  lock_args_method: :lock_args

  def self.lock_args(args)
    [args[0]]  # Lock on first argument only
  end

  def perform(resource_id, options = {})
    # Only one job per resource_id at a time
  end
end

Error Handling

Comprehensive Error Wrapper

# app/jobs/concerns/error_handling.rb
module ErrorHandling
  extend ActiveSupport::Concern

  included do
    around_perform :with_error_handling
  end

  private

  def with_error_handling
    yield
  rescue ActiveRecord::RecordNotFound => e
    # Log but don't re-raise (job completes)
    Rails.logger.warn("#{self.class.name}: Record not found - #{e.message}")
  rescue StandardError => e
    # Log, notify, and re-raise (triggers retry)
    Rails.logger.error("#{self.class.name} failed: #{e.message}")
    Sentry.capture_exception(e, extra: { job_args: arguments })
    raise
  end
end

# Usage
class MyJob < ApplicationJob
  include ErrorHandling
  
  def perform(task_id)
    # Implementation
  end
end

Testing Jobs

# spec/jobs/task_notification_job_spec.rb
require 'rails_helper'

RSpec.describe TaskNotificationJob, type: :job do
  include ActiveJob::TestHelper

  let(:task) { create(:task) }

  describe '#perform' do
    it 'sends notification' do
      expect(SmsService).to receive(:send).with(
        to: task.recipient.phone,
        message: include(task.tracking_number)
      )

      described_class.perform_now(task.id)
    end

    it 'is idempotent' do
      allow(SmsService).to receive(:send)
      
      # First call
      described_class.perform_now(task.id)
      
      # Second call should not send again
      expect(SmsService).not_to receive(:send)
      described_class.perform_now(task.id)
    end

    context 'when task not found' do
      it 'raises error' do
        expect {
          described_class.perform_now(0)
        }.to raise_error(ActiveRecord::RecordNotFound)
      end
    end
  end

  describe 'queueing' do
    it 'queues on correct queue' do
      expect {
        described_class.perform_later(task.id)
      }.to have_enqueued_job.on_queue('notifications')
    end
  end
end

Monitoring & Debugging

Sidekiq Web UI

# config/routes.rb
require 'sidekiq/web'

Rails.application.routes.draw do
  # Protect with authentication
  authenticate :user, ->(u) { u.admin? } do
    mount Sidekiq::Web => '/sidekiq'
  end
end

Useful Commands

# Check queue sizes
redis-cli LLEN queue:default
redis-cli LLEN queue:critical

# Monitor Sidekiq processes
bundle exec sidekiq -q critical -q default

# Clear all queues (DANGEROUS)
redis-cli FLUSHDB

# Check scheduled jobs
redis-cli ZRANGE schedule 0 -1

Anti-Patterns to Avoid

❌ Non-Idempotent Jobs

# WRONG
def perform(user_id)
  User.find(user_id).increment!(:login_count)
end

❌ Large Payloads

# WRONG
def perform(user_data)  # Passing full hash
  User.create!(user_data)
end

# CORRECT
def perform(user_id)
  user = User.find(user_id)
  # Process user
end

❌ Long-Running Jobs

# WRONG - Job runs for hours
def perform
  User.all.each { |u| process(u) }
end

# CORRECT - Batch into smaller jobs
def perform(user_ids)
  User.where(id: user_ids).find_each { |u| process(u) }
end

❌ No Error Handling

# WRONG
def perform(task_id)
  external_api.call(task_id)  # May fail silently
end

# CORRECT
def perform(task_id)
  result = external_api.call(task_id)
  raise "API failed: #{result.error}" unless result.success?
end

Sidekiq 7.x Modern Features

Capsules (Sidekiq 7.2+)

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  # Define separate thread pools for different workloads
  config.capsule("default") do |cap|
    cap.concurrency = 5
    cap.queues = %w[default low]
  end

  config.capsule("critical") do |cap|
    cap.concurrency = 10
    cap.queues = %w[critical high_priority]
  end

  config.capsule("bulk") do |cap|
    cap.concurrency = 20
    cap.queues = %w[bulk imports exports]
  end
end

# Jobs automatically use appropriate capsule based on queue
class CriticalJob < ApplicationJob
  queue_as :critical  # Uses "critical" capsule
end

Embedded Mode (Sidekiq 7.0+)

# config/puma.rb
# Run Sidekiq in-process with Puma
on_worker_boot do
  @sidekiq_pid = fork do
    require 'sidekiq/cli'
    Sidekiq::CLI.instance.run([
      '-c', '5',
      '-q', 'critical,5',
      '-q', 'default,3'
    ])
  end
end

on_worker_shutdown do
  Process.kill('TERM', @sidekiq_pid) if @sidekiq_pid
  Process.wait(@sidekiq_pid) if @sidekiq_pid
rescue Errno::ESRCH, Errno::ECHILD
  # Process already gone
end

Strict Arguments (Sidekiq 7.0+)

# config/initializers/sidekiq.rb
# Enforce strict argument checking
Sidekiq.strict_args!

# Only JSON-native types allowed
class GoodJob < ApplicationJob
  def perform(user_id, options = {})
    # ✅ Works: primitives, arrays, hashes
  end
end

class BadJob < ApplicationJob
  def perform(user)
    # ❌ Fails: ActiveRecord objects
  end
end

Job Iteration (for Long-Running Jobs)

# Gemfile
gem 'job-iteration'

# app/jobs/bulk_update_job.rb
class BulkUpdateJob < ApplicationJob
  include JobIteration::Iteration

  def build_enumerator(cursor:)
    active_record_records_enumerator(
      User.where(active: true),
      cursor: cursor,
      batch_size: 100
    )
  end

  def each_iteration(user, cursor:)
    user.update!(last_seen_at: Time.current)
  end
end

# Graceful interruption support
# Job can be paused and resumed without data loss

Sidekiq Pro Features (Paid)

Batches

# Track completion of multiple related jobs
class BulkProcessingJob < ApplicationJob
  def perform
    batch = Sidekiq::Batch.new
    batch.description = "Process 1000 users"

    batch.on(:success, BulkProcessingCallback)
    batch.on(:complete, BulkProcessingCallback)

    batch.jobs do
      User.find_in_batches(batch_size: 100) do |users|
        users.each do |user|
          ProcessUserJob.perform_async(user.id)
        end
      end
    end
  end
end

class BulkProcessingCallback
  def on_success(status, options)
    # All jobs succeeded
    AdminMailer.bulk_processing_complete.deliver_now
  end

  def on_complete(status, options)
    # All jobs finished (some may have failed)
    if status.failures > 0
      AdminMailer.bulk_processing_with_errors(status.failures).deliver_now
    end
  end
end

Super Fetch (Performance Boost)

# config/initializers/sidekiq.rb
Sidekiq::Pro.configure_server do |config|
  # Fetch jobs in bulk (25 at a time)
  config.super_fetch!

  # Reduce Redis round-trips by ~95%
  # Significantly improves throughput
end

Reliability Features

# config/initializers/sidekiq.rb
Sidekiq::Pro.configure_server do |config|
  # Persistent storage - jobs survive Redis restarts
  config.reliable_fetch!

  # If process dies, jobs return to queue within 30 minutes
  config.reliability_heartbeat_interval = 30.minutes
end

Rate Limiting (Pro)

class ApiCallJob < ApplicationJob
  include Sidekiq::Limiter::Mixin

  sidekiq_options queue: :integrations

  # Limit to 10 concurrent jobs
  sidekiq_options limit: {
    concurrency: 10,
    timeout: 60  # Wait up to 60s for slot
  }

  # Limit by custom key (e.g., per API key)
  def perform(api_key, params)
    # Rate limit per API key
    limiter = Sidekiq::Limiter.concurrent(api_key, 5)

    limiter.within_limit do
      ExternalApi.call(api_key, params)
    end
  end
end

Sidekiq Enterprise Features (Paid)

Unique Jobs (Built-in)

class UniqueProcessingJob < ApplicationJob
  sidekiq_options lock: :until_executed,
                  lock_timeout: 3600

  def perform(resource_id)
    # Only one job per resource_id can execute at a time
    resource = Resource.find(resource_id)
    resource.process!
  end
end

# Lock strategies:
# :until_executing - Unlock when job starts
# :until_executed  - Unlock when job finishes
# :until_expired   - Unlock after lock_timeout

Periodic Jobs (Replacement for sidekiq-scheduler)

# config/initializers/sidekiq.rb
Sidekiq::Enterprise.configure_server do |config|
  config.periodic do |p|
    # Run every day at 2 AM
    p.cron "DailyCleanup", "0 2 * * *", class: "DailyCleanupJob"

    # Run every hour
    p.every "HourlySync", "1h", class: "HourlySyncJob"

    # Run every 30 minutes
    p.every "FrequentTask", "30m", class: "FrequentTaskJob"

    # With custom queue
    p.every "LowPriorityTask", "6h", class: "LowPriorityJob", queue: "low"
  end
end

Rate Limiting (Enterprise)

# Global rate limiting across all workers
class ThrottledJob < ApplicationJob
  include Sidekiq::RateLimiter::Mixin

  # Limit to 100 jobs per minute
  sidekiq_throttle threshold: { limit: 100, period: 60 }

  def perform(user_id)
    # Only 100 of these will execute per minute
  end
end

# Per-argument rate limiting
class PerUserJob < ApplicationJob
  include Sidekiq::RateLimiter::Mixin

  sidekiq_throttle threshold: {
    limit: 10,
    period: 3600,
    key: -> (user_id) { "user:#{user_id}" }
  }

  def perform(user_id)
    # Max 10 jobs per user per hour
  end
end

Rolling Restarts (Zero Downtime)

# Terminal 1: Start new version
bundle exec sidekiq -C config/sidekiq.yml

# Terminal 2: Gracefully stop old version
bundle exec sidekiqctl quiet /tmp/sidekiq.pid
# Workers stop accepting new jobs, finish current ones
# Then:
bundle exec sidekiqctl stop /tmp/sidekiq.pid

Advanced Patterns

Transactional Outbox Pattern

# Ensure jobs are enqueued only if database transaction succeeds
class CreateUserService
  def call(params)
    user = nil

    ApplicationRecord.transaction do
      user = User.create!(params)

      # Job enqueued only if transaction commits
      SendWelcomeEmailJob.perform_later(user.id)

      # For immediate execution on commit
      # after_commit { SendWelcomeEmailJob.perform_later(user.id) }
    end

    user
  end
end

# Better: Use after_commit callback
class User < ApplicationRecord
  after_commit :send_welcome_email, on: :create

  private

  def send_welcome_email
    SendWelcomeEmailJob.perform_later(id)
  end
end

Debouncing Jobs

# app/jobs/search_index_job.rb
class SearchIndexJob < ApplicationJob
  sidekiq_options lock: :until_executed

  def perform(model_class, model_id)
    # Wait 5 seconds to batch multiple updates
    sleep 5

    model = model_class.constantize.find(model_id)
    model.reindex
  end
end

# In model:
after_commit :schedule_reindex

def schedule_reindex
  SearchIndexJob.set(wait: 5.seconds).perform_later(
    self.class.name,
    id
  )
end

Job Chaining

class StepOneJob < ApplicationJob
  def perform(data_id)
    data = Data.find(data_id)
    result = process_step_one(data)

    # Chain to next job
    StepTwoJob.perform_later(data_id, result) if result.success?
  end
end

class StepTwoJob < ApplicationJob
  def perform(data_id, step_one_result)
    data = Data.find(data_id)
    result = process_step_two(data, step_one_result)

    # Chain to final job
    StepThreeJob.perform_later(data_id, result) if result.success?
  end
end

Parent-Child Job Pattern

class ParentJob < ApplicationJob
  def perform(batch_id)
    batch = Batch.find(batch_id)

    batch.items.find_each do |item|
      ChildJob.perform_later(item.id, batch_id)
    end

    # Track completion
    batch.update!(child_jobs_count: batch.items.count)
  end
end

class ChildJob < ApplicationJob
  def perform(item_id, batch_id)
    item = Item.find(item_id)
    item.process!

    # Decrement counter
    batch = Batch.find(batch_id)
    batch.decrement!(:child_jobs_count)

    # Check if all children complete
    CompleteParentJob.perform_later(batch_id) if batch.child_jobs_count.zero?
  end
end

Performance Optimization

Connection Pooling

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  # Match concurrency + 5
  config.redis = {
    url: ENV['REDIS_URL'],
    pool_size: (ENV['SIDEKIQ_CONCURRENCY']&.to_i || 10) + 5
  }
end

# config/database.yml
production:
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  # Sidekiq needs pool >= concurrency

Bulk Enqueuing

# Instead of this (N Redis calls):
users.each do |user|
  SendEmailJob.perform_later(user.id)
end

# Do this (1 Redis call):
args = users.map { |user| [user.id] }
Sidekiq::Client.push_bulk(
  'class' => SendEmailJob,
  'queue' => 'notifications',
  'args' => args
)

Job Compression (for Large Payloads)

# app/jobs/large_data_job.rb
class LargeDataJob < ApplicationJob
  before_perform do |job|
    # Decompress arguments
    if job.arguments.first.is_a?(String) && job.arguments.first.start_with?('COMPRESSED:')
      compressed = job.arguments.first.sub('COMPRESSED:', '')
      decompressed = Zlib::Inflate.inflate(Base64.decode64(compressed))
      job.arguments[0] = JSON.parse(decompressed)
    end
  end

  def perform(large_data)
    # Process data
  end

  def self.perform_later_compressed(data)
    json = data.to_json
    compressed = Base64.encode64(Zlib::Deflate.deflate(json))
    perform_later("COMPRESSED:#{compressed}")
  end
end

Database Query Optimization

# WRONG: N+1 queries in job
class ProcessUsersJob < ApplicationJob
  def perform(user_ids)
    User.where(id: user_ids).each do |user|
      user.orders.each do |order|  # N+1!
        process_order(order)
      end
    end
  end
end

# CORRECT: Eager loading
class ProcessUsersJob < ApplicationJob
  def perform(user_ids)
    User.includes(:orders).where(id: user_ids).each do |user|
      user.orders.each do |order|
        process_order(order)
      end
    end
  end
end

Monitoring & Observability

Custom Metrics

# app/jobs/concerns/metrics.rb
module JobMetrics
  extend ActiveSupport::Concern

  included do
    around_perform :track_job_metrics
  end

  private

  def track_job_metrics
    start_time = Time.current

    yield

    duration = (Time.current - start_time) * 1000

    StatsD.increment("jobs.#{self.class.name.underscore}.success")
    StatsD.timing("jobs.#{self.class.name.underscore}.duration", duration)
  rescue StandardError => e
    StatsD.increment("jobs.#{self.class.name.underscore}.failure")
    raise
  end
end

# Usage
class MyJob < ApplicationJob
  include JobMetrics
end

Logging Best Practices

class WellLoggedJob < ApplicationJob
  def perform(resource_id)
    logger.info({
      job: self.class.name,
      resource_id: resource_id,
      jid: jid,
      event: 'started'
    }.to_json)

    resource = Resource.find(resource_id)
    result = process(resource)

    logger.info({
      job: self.class.name,
      resource_id: resource_id,
      jid: jid,
      event: 'completed',
      result: result
    }.to_json)
  rescue StandardError => e
    logger.error({
      job: self.class.name,
      resource_id: resource_id,
      jid: jid,
      event: 'failed',
      error: e.class.name,
      message: e.message
    }.to_json)
    raise
  end
end

Dead Job Alerts

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.death_handlers << ->(job, exception) do
    # Job exhausted all retries
    Rails.logger.error({
      event: 'job_died',
      job_class: job['class'],
      args: job['args'],
      error: exception.message,
      retry_count: job['retry_count']
    }.to_json)

    # Alert team
    if defined?(Sentry)
      Sentry.capture_message("Job died: #{job['class']}", extra: {
        job: job,
        exception: exception
      })
    end

    # Slack notification for critical jobs
    if job['queue'] == 'critical'
      SlackNotifier.notify(
        "🚨 Critical job failed: #{job['class']}",
        exception.message
      )
    end
  end
end

Checklist Before Creating Jobs

[ ] Job is idempotent (safe to run multiple times)
[ ] Passes IDs, not objects
[ ] Has appropriate retry configuration
[ ] Handles expected errors gracefully
[ ] Logs failures for debugging
[ ] Has correct queue assignment
[ ] Has tests covering happy path and failures
[ ] Considers Sidekiq 7.x features (capsules, strict args)
[ ] Uses job iteration for long-running tasks
[ ] Implements proper monitoring and metrics
[ ] Documents expected runtime and dependencies