Гарантии доставки и идемпотентность

Предпосылки: архитектура Sidekiq, жизненный цикл задачи, гарантии доставки, reliability patterns § idempotency.

Жизненный цикл задачи | Retry и обработка ошибок

Sidekiq-процесс обрабатывает десять задач одновременно. OOM-killer убивает процесс. BRPOP извлёк все десять задач из Redis — они уже не в очереди. Процесс мёртв — они не в работе. Десять задач потеряны.

Это не единственное место, где задача может пропасть. В Sidekiq три точки потери — и на каждой по умолчанию задача не защищена.

Три точки потери

BasicFetch: Redis → Processor

Самая наглядная точка. BRPOP — это простая очередь: элемент удаляется из Redis в момент чтения. Между «забрал» и «обработал» нет страховки.

Сценарий: Processor выполнил BRPOP, получил задачу, начал perform. Процесс убит SIGKILL (OOM, kill -9). Задача уже не в Redis и не в работающем процессе — потеряна.

При graceful shutdown (SIGTERM) Sidekiq возвращает in-flight задачи обратно в Redis (bulk_requeue). Но это работает только если процесс получил сигнал, имеет время на shutdown (timeout по умолчанию 25 секунд), и Redis доступен.

Push: client → Redis

Client выполняет LPUSH для отправки задачи в Redis. Если Redis недоступен в этот момент — perform_async выбросит исключение, и постановка не состоится. Задача «потеряна» только в том смысле, что она так и не попала в очередь.

Scheduling: sorted set → queue

Poller перемещает «созревшие» задачи из sorted sets (schedule, retry) в рабочие очереди. Для этого нужны три шага: прочитать задачу (ZRANGEBYSCORE), удалить из sorted set (ZREM), добавить в рабочую очередь (LPUSH). По умолчанию эти шаги не атомарны. Crash между ZREM и LPUSH — задача удалена из sorted set, но не попала в очередь — потеря. Crash между LPUSH и ZREM — задача в очереди, но не удалена из sorted set — дубликат при следующей проверке.

Контракт: какая гарантия?

По умолчанию (OSS Sidekiq) каждая из трёх точек может потерять задачу — это at-most-once: задача выполнится не более одного раза (возможно, ноль).

Pro-механизмы (super_fetch!, reliable_push!, reliable_scheduler!) значительно сужают окна потерь, приближая систему к at-least-once. Но у каждого механизма остаются оговорки (in-memory буфер теряется при рестарте, reliable scheduler несовместим с Redis Cluster). Более точная формулировка: best-effort at-least-once — система делает всё возможное, чтобы задача была выполнена хотя бы раз, но не гарантирует это при любых сбоях.

Важно: покупка Pro без явного включения механизмов (config.super_fetch! и т.д.) не меняет гарантий. Каждый механизм требует opt-in.

At-least-once → нужна идемпотентность

At-least-once означает: задача может выполниться повторно. Retry после ошибки, orphan recovery после crash, дубликат из-за scheduling — всё это приводит к повторному выполнению. Если задача делает balance += 100, повторное выполнение удвоит сумму.

Exactly-once на транспортном уровне невозможна. Решение: at-least-once + идемпотентность = exactly-once по результату.

Три паттерна для идемпотентных задач:

# 1. Проверка состояния перед действием
def perform(order_id)
  order = Order.find(order_id)
  return if order.processed?
  order.process!
end
 
# 2. Unique constraint в базе
def perform(order_id)
  Payment.create!(order_id: order_id, idempotency_key: "charge-#{order_id}")
rescue ActiveRecord::RecordNotUnique
  # уже создано — ничего не делать
end
 
# 3. Idempotency key с внешним сервисом
def perform(order_id)
  Stripe::Charge.create(
    amount: 1000,
    idempotency_key: "order-#{order_id}"
  )
end

Идемпотентность защищает от повторных выполнений. Но есть проблема до выполнения — задача может оказаться в очереди, хотя породившая её операция не состоялась.

Ловушка: perform_async внутри транзакции

ActiveRecord::Base.transaction оборачивает SQL-операции в транзакцию базы данных: если внутри возникает ошибка или raise ActiveRecord::Rollback, все изменения в базе откатываются.

ActiveRecord::Base.transaction do
  order = Order.create!(...)
  SendConfirmationJob.perform_async(order.id)  # LPUSH в Redis — уже выполнен!
  raise ActiveRecord::Rollback  # order откатится, но job уже в Redis
end

perform_async выполняет LPUSH немедленно — Redis не участвует в транзакции базы данных. Если транзакция откатится, задача в Redis обработает несуществующий order.

Два решения:

# 1. after_commit callback
class Order < ApplicationRecord
  after_commit :enqueue_confirmation, on: :create
 
  private
 
  def enqueue_confirmation
    SendConfirmationJob.perform_async(id)
  end
end
 
# 2. Transactional Push (Sidekiq 7.0+)
Sidekiq.transactional_push!
# Теперь perform_async внутри транзакции буферизует задачу
# и отправит в Redis только после commit

Transactional Push интегрируется с ActiveRecord: если perform_async вызван внутри транзакции, задача буферизуется и отправляется в Redis только после успешного COMMIT. При rollback — задача отбрасывается. Вне транзакции perform_async работает как обычно — немедленный LPUSH.

Две важные оговорки из текущей документации: для Rails < 7.2 нужен gem after_commit_everywhere, а push_bulk/perform_bulk не буферизуются этой функцией.


Потери, дубликаты, идемпотентность — всё это про крайние случаи. В штатной работе задачи чаще падают по обычной причине: email-сервис вернул 500, база данных не ответила за timeout, API ограничил частоту. Для таких случаев в Sidekiq есть retry.


Жизненный цикл задачи | Retry и обработка ошибок

Sources