Платёжный провайдер шлёт вебхук об успешной оплате, мы кладём его в Sidekiq, джоба начисляет бонусы. Вчера у одного заказа бонусы начислились дважды: провайдер не дождался от нас 200, повторил вебхук через 30 секунд, и мы обработали оба. Проверка сейчас такая:
def perform(payment_id)
payment = Payment.find(payment_id)
return if payment.bonus_accrued?
Bonus.accrue!(payment)
payment.update!(bonus_accrued: true)
end
Две джобы шли параллельно, и обе увидели false. Как такое правильно закрывают?
Проверка флага в Ruby всегда гонка: между чтением и записью есть окно. Закрывают на уровне базы. Первое: сохраняйте id события от провайдера в таблицу с уникальным индексом и вставляйте его до любой обработки, повтор просто не пройдёт:
result = WebhookEvent.insert(
{ provider_event_id: params[:id], payload: params.to_unsafe_h },
unique_by: :provider_event_id
)
return head(:ok) if result.rows.empty?
ProcessPaymentJob.perform_async(params[:payment_id])
head :ok
Второе: в самой джобе берите платёж с блокировкой, payment.with_lock { ... }, и проверяйте флаг уже внутри. И отвечайте провайдеру 200 сразу после записи события, а не после обработки, тогда и повторов станет меньше.
Плюс уникальный индекс на bonuses.payment_id, если бонус по платежу бывает один. Последний рубеж, когда всё остальное подвело.
Закрыла все три дыры: таблица событий с уникальным id, with_lock в джобе и индекс на бонусах. Задвоенный заказ поправили руками. У провайдера в тестовом режиме можно слать повторы, прогнали 50 штук подряд, начисление одно.
про 200 до обработки очень правильно, у многих провайдеров таймаут секунд 5-10, а джоба в очереди может и подождать