Дизайн задач

Предпосылки: Sidekiq: сигналы и deploy, reliability patterns § bulkhead, event-driven architecture, message queues.

Сигналы и deploy | Concurrency и масштабирование

ProcessOrderJob#perform делает четыре вещи: резервирует товар, списывает оплату, отправляет email, записывает аналитику. Аналитика падает — весь job уходит в retry. Повторная попытка списывает оплату второй раз. Проблема не в retry — проблема в том, как спроектирован job.

Один большой job — проблема

Наивный подход — один job, который делает всё:

class ProcessOrderJob
  include Sidekiq::Job
 
  def perform(order_id)
    order = Order.find(order_id)
    PaymentService.charge(order)      # 1. списать оплату
    WarehouseService.reserve(order)    # 2. зарезервировать
    UserMailer.confirmation(order).deliver_now  # 3. email
    AnalyticsService.track(order)      # 4. аналитика
  end
end

Аналитика упала — exception поднимается до JobRetry, задача уходит в retry. При повторной попытке выполнятся все четыре шага заново — включая charge, который уже списал деньги. Двойное списание.

Проблема глубже, чем повторное списание. Некритичное действие (аналитика) роняет критичное (оплату). Это частный случай cascading failure: сбой в одном компоненте бьёт по всему потоку. В reliability patterns решение — bulkhead: изолировать критичное от некритичного, чтобы сбой одного не затрагивал другое.

Один job = одна атомарная операция

Принцип: каждый job делает одну операцию, которая либо полностью выполняется, либо нет. Job должен быть идемпотентным — безопасным для повторного выполнения.

class ChargePaymentJob
  include Sidekiq::Job
 
  def perform(order_id)
    order = Order.find(order_id)
    return if order.charged?
    PaymentService.charge(order)
  end
end

Одна операция, один retry-цикл, один набор ошибок. Если ChargePaymentJob упадёт — повторится только charge. Analytics работает независимо в своём job с отдельным retry.

Сначала границы зависимости, потом fan-out

Разбить большой job на маленькие недостаточно. Сначала нужно понять, какие шаги действительно независимы, а какие образуют причинную цепочку.

Оплата, резервирование товара и отправка письма неравноправны:

  • email нельзя отправлять до успешной оплаты;
  • резерв нельзя подтверждать, если платёж не прошёл;
  • аналитику и вторичные уведомления часто можно отделить от критичного пути.

Поэтому зависимые шаги лучше оформлять как явную последовательность маленьких идемпотентных jobs:

class ChargePaymentJob
  include Sidekiq::Job
 
  def perform(order_id)
    order = Order.find(order_id)
    return if order.charged?
 
    PaymentService.charge(order)
    ReserveStockJob.perform_async(order_id)
  end
end
 
class ReserveStockJob
  include Sidekiq::Job
 
  def perform(order_id)
    order = Order.find(order_id)
    return if order.stock_reserved?
 
    WarehouseService.reserve(order)
    SendConfirmationJob.perform_async(order_id)
  end
end

Retry теперь повторяет только тот шаг, который реально не завершился. Если падает резервирование, повтор не трогает уже успешный платёж. Если падает письмо, заказ не оплачивается повторно.

Fan-out: один job ставит несколько независимых дочерних jobs

Fan-out имеет смысл там, где одно событие запускает несколько независимых ветвей. Здесь fan-out означает: один job ставит несколько дочерних jobs, и сбой одной ветви не откатывает остальные.

После того как заказ уже успешно оформлен, можно отдельно запустить некритичные побочные действия:

class OrderCompletedJob
  include Sidekiq::Job
 
  def perform(order_id)
    SendConfirmationJob.perform_async(order_id)
    TrackAnalyticsJob.perform_async(order_id)
    WriteAuditLogJob.perform_async(order_id)
  end
end

Каждый дочерний job:

  • со своим retry-циклом (analytics может упасть 10 раз — payment не пострадает)
  • со своей очередью (критичные в critical, аналитика в low)
  • со своим набором ошибок (RateLimitError у API, SMTP timeout у email)

Для массовых операций (обработать 10 000 записей) — тот же паттерн, но с perform_bulk (метод класса, который группирует задачи в чанки и отправляет за несколько round-trip к Redis вместо отдельного вызова на каждую задачу):

class BatchImportJob
  include Sidekiq::Job
 
  def perform(file_id)
    rows = CsvFile.find(file_id).parse
    ImportRowJob.perform_bulk(rows.map { |r| [r.id] })
  end
end

Fan-out хорошо изолирует независимые ветви. Но как только появляется вопрос «а когда закончились все дочерние jobs?», простой постановки в очередь уже недостаточно.

Когда нужна координация: Batches

Нужно отправить итоговый email после завершения всего импорта, или оповестить пользователя, что заказ полностью обработан.

Command vs Event: модель интеграции

perform_async — это команда в терминах event-driven architecture: вызывающий код знает конкретного получателя и ожидает конкретное действие. ChargePaymentJob.perform_async(order_id) — «ты, ChargePaymentJob, обработай этот заказ».

Альтернативная модель — событие: «заказ создан, кому интересно — обрабатывайте». Вызывающий код не знает получателей. Это территория sub и специализированных брокеров сообщений. Sidekiq работает в модели команд — point-to-point с конкурирующими consumers.

Когда Sidekiq-команды начинают обрастать сложной маршрутизацией (один job ставит 15 sub-jobs, и каждый из них — ещё несколько), это сигнал, что система может выиграть от перехода к событийной модели. Но для большинства Rails-приложений прямые команды проще и достаточны.


Задачи спроектированы: маленькие, атомарные, идемпотентные, каждая со своим retry. Их стало десять тысяч в час. Одного процесса с пятью потоками не хватает — как масштабировать?


Сигналы и deploy | Concurrency и масштабирование

Sources