Rails 7.1, Ruby 3.3, PostgreSQL 16, джобы на GoodJob. Платёжный шлюз иногда шлёт одно и то же уведомление дважды с разницей в десятки миллисекунд. Проверка !payment.succeeded? вроде есть, но раз в несколько дней у кого-то бонусы начисляются два раза. Подпись проверяем в before_action, здесь убрала, чтобы не загромождать.
class PaymentWebhooksController < ApplicationController
skip_forgery_protection
def create
payment = Payment.find_by!(external_id: params[:payment_id])
if params[:status] == "succeeded" && !payment.succeeded?
payment.update!(status: "succeeded")
BonusAccrualJob.perform_later(payment.user_id, payment.amount)
end
head :ok
end
end
Где дыра?
Классическая гонка. Оба запроса читают платёж, пока он ещё pending, оба проходят проверку, оба ставят джобу. Проверка в Ruby от этого не спасает, атомарность нужна в базе. Самое простое - условный UPDATE, который сработает ровно у одного из двух:
def create
payment = Payment.find_by!(external_id: params[:payment_id])
if params[:status] == "succeeded"
updated = Payment.where(id: payment.id)
.where.not(status: "succeeded")
.update_all(status: "succeeded", updated_at: Time.current)
BonusAccrualJob.perform_later(payment.id) if updated == 1
end
head :ok
end
Второе и даже более важное: начисление должно быть идемпотентным само по себе. Передавай в джобу id платежа, а не сумму, и заведи уникальный индекс на bonus_accruals.payment_id. Тогда, даже если джоба выполнится дважды после ретрая, второе начисление упадёт на уникальности.
Можно и через payment.with_lock с проверкой статуса внутри, но условный UPDATE лучше: никто не ждёт на блокировке.
Выкатили условный UPDATE, id в джобе и уникальный индекс. Индекс с первого раза не создался: в таблице нашлось 14 дублей за полгода, разбирали руками и списывали лишние бонусы. За две недели после выкладки в логе девять отброшенных дублей, двойных начислений ноль.