Платёжный вебхук пр...
 
Уведомления
Очистить всё

Платёжный вебхук приходит дважды, и бонусы начисляются два раза

6 Сообщения
3 Пользователи
5 Реакции
474 Просмотры
Юлия Ф.
Сообщения: 11
Автор темы
Разобрался с блоками фулстек-разработчица
Присоединился: 3 года назад
[#37]

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

Где дыра?


5 ответов
GalinaKorneva
Сообщения: 30
Читает чужие PR бэкенд-разработчица
Присоединился: 4 года назад

Классическая гонка. Оба запроса читают платёж, пока он ещё 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. Тогда, даже если джоба выполнится дважды после ретрая, второе начисление упадёт на уникальности.


Ответ
2 ответа
Юлия Ф.
Присоединился: 3 года назад

Разобрался с блоками фулстек-разработчица
Сообщения: 11

А как правильно проглотить ошибку уникальности? Если create! в джобе упадёт с RecordNotUnique, GoodJob же начнёт её ретраить.


Ответ
GalinaKorneva
Присоединился: 4 года назад

Читает чужие PR бэкенд-разработчица
Сообщения: 30

discard_on прямо в джобе, это обычный Active Job:

class BonusAccrualJob < ApplicationJob
  discard_on ActiveRecord::RecordNotUnique

  def perform(payment_id)
    payment = Payment.find(payment_id)
    BonusAccrual.create!(payment: payment, user_id: payment.user_id, amount: payment.bonus_amount)
  end
end

Дубль падает на индексе и отбрасывается без ретраев, а в логе остаётся след, что он был.


Ответ
kostya.sokurov
Сообщения: 73
Держит прод бэкенд-разработчик, senior
Присоединился: 4 года назад

Можно и через payment.with_lock с проверкой статуса внутри, но условный UPDATE лучше: никто не ждёт на блокировке.


Ответ
Юлия Ф.
Сообщения: 11
Автор темы
Разобрался с блоками фулстек-разработчица
Присоединился: 3 года назад

Выкатили условный UPDATE, id в джобе и уникальный индекс. Индекс с первого раза не создался: в таблице нашлось 14 дублей за полгода, разбирали руками и списывали лишние бонусы. За две недели после выкладки в логе девять отброшенных дублей, двойных начислений ноль.


Ответ