Architecture

Проектируйте под вебхук, который приходит дважды

Вторая копия платёжного события — не новая работа. Уникальный ключ, исходящий ящик и аннулирование, которое не правит прошлое.

Design for the webhook that arrives twice

Опасный запрос — не первый. Это тот же запрос, который приходит снова, потому что отправитель не услышал первый ответ или услышал и всё равно повторил. Платёжные провайдеры так делают. Очереди так делают. Браузер так делает, когда человек нажимает кнопку дважды. Если система считает вторую копию новой работой, вы спишете дважды, отправите дважды или запишете две строки книги на одну ставку.

Исходите из дубля

Я проектирую обработчик так, чтобы вторая доставка была успехом, который ничего не делает. Первая делает работу и сохраняет ключ: id события провайдера или ключ, который прислал клиент, уникальный в базе. Вторая находит ключ и возвращает тот же результат. Без второго списания. Без второго письма. Без второй строки.

Ограничение уникальности — та часть, которой я доверяю. Проверка в коде приложения за миг до insert проигрывает гонку, когда два worker просыпаются вместе. База эту гонку не проигрывает. Проигравший получает конфликт и превращает его в «уже сделано».

Первый приходВторой приход
ПоискКлюч новыйКлюч уже сохранён
ПисатьОдна строка, один побочный эффектНовой строки нет
ОтветитьПринятоТот же, уже принятый
Если процесс умрёт на полпутиИсходящий ящик всё ещё должен сообщениеВторого оно не должно

Сообщение уходит вместе с записью, или не уходит

Отправить письмо или сообщение очереди внутри запроса и до commit — так получается сообщение о транзакции, которая откатилась. Я пишу исходящее сообщение в той же транзакции, что и деловую строку, а отдельный worker его публикует. Если процесс умирает, строка и сообщение либо оба на месте, либо обоих нет. Worker может повторить публикацию. Обработчик не должен повторять списание.

Аннулирование — новая проводка, не правка

Когда что-то нужно отменить, я не перезаписываю исходную строку. Компенсирующая запись говорит, что изменилось, и оставляет первую строку читаемой. Через неделю всё ещё можно ответить, во что верили в 14:02. Правка строки отвечает только на то, во что верят сейчас, и без свидетеля.

Если повтор может заставить операцию случиться дважды, операция не закончена.

Назовите ключ до того, как писать обработчик

Ключ — это не «хеш всего запроса», если вы не проверили, что отправитель меняет между повторами. Платёжный провайдер пришлёт тот же id события с новой меткой времени доставки. Если метка внутри ключа, вторая доставка выглядит новой, и вы построили баг, который хотели предотвратить. Я беру id, который отправитель обещает стабильным. Если не обещает, я требую, чтобы вызывающий прислал свой, и отвергаю вызов, который приходит без него.

Потом я записываю неудачу туда же, куда и успех. Конфликт для отправителя — не страница ошибки. Это ожидаемый ответ на повтор. Отправитель должен иметь возможность остановиться. Если я отвечу 500, будет третья попытка и четвёртая, и инцидент станет очередью одинаковой работы.

Я не считаю это готовым, потому что счастливый путь вернул 200. Тест отправляет ту же нагрузку ещё раз и проверяет один побочный эффект. Потом убивает worker между commit и публикацией и проверяет, что сообщение всё ещё уходит, один раз. Эти два теста короче инцидента, который они заменяют. Я начинаю оттуда.