返回 Skill 列表
extension
分类: 开发与工程无需 API Key

rails-background-jobs

针对使用Solid Queue的Rails后台任务的专业技能。在创建任务、安排任务、实现重复性任务、测试任务或监控任务队列时使用。包括可靠的后台处理最佳实践。

person作者: jakexiaohubgithub

Rails Background Jobs

Modern background processing with Solid Queue and Mission Control.

When to Use This Skill

  • Creating background jobs
  • Scheduling delayed tasks
  • Setting up recurring jobs (cron-like)
  • Testing jobs with RSpec
  • Monitoring jobs with Mission Control
  • Implementing retry strategies
  • Handling job failures
  • Processing bulk operations

Tech Stack

# Gemfile
gem "solid_queue"           # Background jobs
gem "mission_control-jobs"  # Web UI for monitoring

Setup

# Install Solid Queue
$ bin/rails solid_queue:install

# This creates:
# - db/queue_schema.rb
# - config/queue.yml
# - config/recurring.yml
# config/application.rb
config.active_job.queue_adapter = :solid_queue

Basic Job

# app/jobs/send_welcome_email_job.rb
class SendWelcomeEmailJob < ApplicationJob
  queue_as :default

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

Queue Configuration

Queue Names

class SendWelcomeEmailJob < ApplicationJob
  queue_as :mailers  # Specific queue

  # Or dynamic queue
  queue_as do
    user.premium? ? :high_priority : :default
  end

  def perform(user)
    # ...
  end
end

Retry Configuration

class ProcessPaymentJob < ApplicationJob
  queue_as :payments

  # Retry up to 5 times with exponential backoff
  retry_on PaymentGatewayError, wait: :exponentially_longer, attempts: 5

  # Don't retry certain errors
  discard_on InvalidCardError

  # Custom retry logic
  retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3

  def perform(order_id)
    order = Order.find(order_id)
    PaymentGateway.charge(order)
  end
end

Job Callbacks

class ReportGenerationJob < ApplicationJob
  before_perform :log_start
  after_perform :log_completion
  around_perform :measure_time

  def perform(report_id)
    report = Report.find(report_id)
    report.generate!
  end

  private

  def log_start
    Rails.logger.info "Starting report generation"
  end

  def log_completion
    Rails.logger.info "Completed report generation"
  end

  def measure_time
    start = Time.current
    yield
    duration = Time.current - start
    Rails.logger.info "Report took #{duration}s"
  end
end

Scheduling Jobs

Immediate Execution

# Enqueue now
SendWelcomeEmailJob.perform_later(user.id)

# With options
SendWelcomeEmailJob.set(queue: :high_priority, priority: 10)
  .perform_later(user.id)

Delayed Execution

# Run in 1 hour
SendReminderJob.set(wait: 1.hour).perform_later(user.id)

# Run at specific time
SendNewsletterJob.set(wait_until: Date.tomorrow.noon).perform_later

# Run in 2 days
ExportDataJob.set(wait: 2.days).perform_later(user.id)

Bulk Enqueuing

# Better: Use perform_all_later (Rails 7.1+)
jobs = User.pluck(:id).map do |user_id|
  SendWelcomeEmailJob.new(user_id)
end

ActiveJob.perform_all_later(jobs)

Recurring Jobs

Configuration

# config/recurring.yml
production:
  cleanup_old_records:
    class: CleanupJob
    schedule: every day at 2am

  send_daily_digest:
    class: DailyDigestJob
    schedule: every day at 8am
    args: ["digest"]

  process_payments:
    class: ProcessPaymentsJob
    schedule: every 15 minutes

  generate_reports:
    class: GenerateReportsJob
    schedule: every monday at 9am
    args: ["weekly"]

Recurring Job Class

# app/jobs/cleanup_job.rb
class CleanupJob < ApplicationJob
  queue_as :maintenance

  def perform
    # Clean old records
    OldRecord.where("created_at < ?", 90.days.ago).delete_all

    # Clean expired sessions
    ActiveRecord::SessionStore::Session
      .where("updated_at < ?", 30.days.ago)
      .delete_all

    Rails.logger.info "Cleanup completed"
  end
end

Schedule Syntax

# Every X minutes/hours/days
schedule: every 5 minutes
schedule: every 2 hours
schedule: every day

# Specific times
schedule: every day at 3pm
schedule: every monday at 9am
schedule: every 1st of month at 8am

# Multiple times
schedule: every day at 9am, 3pm, 9pm

Testing Jobs

Basic Job Test

# spec/jobs/send_welcome_email_job_spec.rb
RSpec.describe SendWelcomeEmailJob, type: :job do
  let(:user) { create(:user) }

  describe "#perform" do
    it "sends welcome email" do
      expect {
        described_class.perform_now(user.id)
      }.to change { ActionMailer::Base.deliveries.count }.by(1)
    end

    it "sends email to correct user" do
      described_class.perform_now(user.id)

      mail = ActionMailer::Base.deliveries.last
      expect(mail.to).to include(user.email)
    end
  end

  describe "enqueuing" do
    it "enqueues job" do
      expect {
        described_class.perform_later(user.id)
      }.to have_enqueued_job(described_class).with(user.id)
    end

    it "enqueues on correct queue" do
      expect {
        described_class.perform_later(user.id)
      }.to have_enqueued_job.on_queue("mailers")
    end

    it "schedules delayed job" do
      expect {
        described_class.set(wait: 1.hour).perform_later(user.id)
      }.to have_enqueued_job.at(1.hour.from_now)
    end
  end
end

Testing with perform_enqueued_jobs

RSpec.describe "User registration", type: :request do
  include ActiveJob::TestHelper

  it "sends welcome email" do
    perform_enqueued_jobs do
      post users_path, params: {
        user: { email: "user@example.com", name: "John" }
      }
    end

    expect(ActionMailer::Base.deliveries.count).to eq(1)
  end
end

Monitoring

Mission Control

# config/routes.rb
Rails.application.routes.draw do
  mount MissionControl::Jobs::Engine, at: "/jobs"
end

Access at: http://localhost:3000/jobs

Features:

  • View queued, running, and failed jobs
  • Retry failed jobs
  • Pause/resume queues
  • View job history
  • Monitor performance

Running Workers

# Development
$ bin/jobs

# Production
$ bundle exec rake solid_queue:start

Best Practices

1. Keep Jobs Idempotent

Jobs should be safe to run multiple times:

# GOOD - Idempotent
class UpdateUserStatusJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    user.update(status: "active") unless user.active?
  end
end

# BAD - Not idempotent
class IncrementCounterJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    user.increment!(:login_count)  # Dangerous if runs twice
  end
end

2. Pass IDs, Not Objects

# GOOD - Pass ID
SendEmailJob.perform_later(user.id)

class SendEmailJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)  # Fetch fresh data
    UserMailer.welcome(user).deliver_now
  end
end

# BAD - Pass object (stale data risk)
SendEmailJob.perform_later(user)

3. Break Large Jobs into Smaller Ones

# GOOD - Parent job enqueues smaller jobs
class ProcessBatchJob < ApplicationJob
  def perform(batch_id)
    batch = Batch.find(batch_id)

    batch.items.find_each do |item|
      ProcessItemJob.perform_later(item.id)
    end
  end
end

# BAD - One huge job
class ProcessAllItemsJob < ApplicationJob
  def perform
    Item.find_each do |item|  # Could timeout
      item.process!
    end
  end
end

4. Handle Failures Gracefully

class SendNewsletterJob < ApplicationJob
  retry_on MailerError, wait: :exponentially_longer, attempts: 5

  discard_on ActiveRecord::RecordNotFound do |job, error|
    Rails.logger.error "User not found: #{job.arguments.first}"
  end

  def perform(user_id)
    user = User.find(user_id)
    NewsletterMailer.send_to(user).deliver_now
  rescue => e
    ErrorTracker.notify(e, user_id: user_id)
    raise
  end
end

5. Set Appropriate Timeouts

class LongRunningJob < ApplicationJob
  def perform
    Timeout.timeout(5.minutes) do
      # Long-running task
    end
  rescue Timeout::Error
    Rails.logger.error "Job timed out"
    raise  # Will trigger retry
  end
end

Common Patterns

Conditional Enqueuing

class User < ApplicationRecord
  after_create :send_welcome_email

  private

  def send_welcome_email
    SendWelcomeEmailJob.perform_later(id) if confirmed?
  end
end

Error Tracking

class ApplicationJob < ActiveJob::Base
  rescue_from StandardError do |exception|
    ErrorTracker.notify(exception, job: self.class.name)
    raise exception  # Re-raise to trigger retry
  end
end

Reference Documentation

For comprehensive job patterns:

  • Background jobs guide: background-jobs.md (detailed examples and advanced patterns)