Идемпотентность: как сделать вебхуки и фоновые задачи безопасными для повтора
At-least-once доставка означает, что один и тот же вебхук придёт не раз — дубликаты неизбежны. Разбираем паттерн ключа идемпотентности, upsert и то, где помогают дедуп startNew, повторы и dead-letter-очередь Inquir, а где идемпотентным обязаны быть вы.
Повторная доставка запроса — это не баг в чужой системе, а штатный режим работы любого провайдера вебхуков и любой долговечной очереди задач, которую вы когда-либо подключите. Stripe пересылает неуспешное событие payment_intent.succeeded в течение 72 часов. GitHub повторяет доставку три дня. Slack помечает приложение как медленное, если вы не ответили за три секунды, и пробует снова. Ваша собственная фоновая очередь перезапустит задачу, чьё подтверждение потерялось по дороге. И если вторая, третья или десятая копия одного и того же события дважды спишет деньги с клиента или запишет дубликат строки — проблема не в сети. Проблема в том, что ваш обработчик изначально не был безопасен для повтора.
Эта статья — о том, как сделать его безопасным. Разберём, почему at-least-once delivery делает дубликат вебхука неизбежным, что такое паттерн ключа идемпотентности (idempotency key), как работает естественная идемпотентность через upsert, в чём разница exactly-once vs at-least-once и — честно — где проходит граница между тем, что Inquir Compute дедуплицирует за вас, и тем, что вы обязаны сделать идемпотентным сами.
Почему at-least-once delivery делает дубликаты вебхуков неизбежными
Любая надёжная система обмена сообщениями в проде даёт одну и ту же гарантию доставки: at-least-once delivery, «хотя бы один раз». Это значит, что сообщение придёт один или больше раз. Никогда ноль — но и никогда гарантированно ровно один. Причина проста и неустранима для распределённых систем: отправитель не может отличить «получатель не получил моё сообщение» от «получатель получил его, обработал, а подтверждение потерялось на обратном пути». Столкнувшись с этой неоднозначностью, корректная система шлёт повторно. Именно повторная отправка не даёт вам терять события; плата за это — что иногда вы получаете их дважды.
Поэтому любой серьёзный провайдер повторяет доставку настойчиво. Вебхук Stripe повторяется до 72 часов, пока не увидит 2xx. GitHub пересылает три дня. Slack ждёт ответа в течение трёх секунд и стреляет снова, если не дождался. Наложите на это реальность: балансировщик отвалился по таймауту посреди запроса, деплой перекатил под, пока тот ещё писал в базу, блокировка в БД растянула обработчик с двухсот миллисекунд до четырёх секунд. Каждый из этих случаев превращает одно логическое событие в две или больше физических доставок.
То же самое происходит внутри вашей инфраструктуры, а не только на границе с провайдером. Долговечная очередь задач Inquir по своей природе at-least-once. Когда вы ставите фоновую работу через global.durable.startNew(), платформа сохраняет её в Postgres, повторяет с backoff (если вы это включили) и «пожинает» (reaps) задачи, превысившие visibility timeout, — а значит задача, чей воркер завис, может быть подхвачена и выполнена снова. Ещё важнее: гарантированного порядка нет. Два события, пришедшие почти одновременно, могут обработаться не в той последовательности. Дубликаты и переупорядочивание — это не пограничные случаи, которые залатают потом. Это и есть контракт. Проектируйте под них с первой строки обработчика.
Exactly-once vs at-least-once: что можно купить, а что нет
Инженеры тянутся к «exactly-once» так, будто это галочка в настройках. В большинстве случаев это не так. Настоящая exactly-once доставка — гарантия, что сообщение пройдёт через сеть и будет передано вашему коду ровно один раз — в общем случае невозможна по той же причине потерянного подтверждения. Любая система, рекламирующая «exactly-once», внутри устроена как at-least-once транспорт плюс слой дедупликации. Она не отменила дубликаты — она спрятала их за проверкой дедупа.
Это различие важно, потому что оно показывает, где на самом деле лежит работа. Купить exactly-once доставку нельзя, но можно инженерно построить эффект exactly-once — его ещё называют effectively-once processing. Формула такая:
at-least-once delivery + идемпотентная обработка = effectively-once эффект
Идемпотентность — это свойство, при котором однократное применение операции и многократное её применение дают один и тот же результат. Если обработчик идемпотентен, вам уже всё равно, сколько раз доставлено сообщение. Первая доставка делает работу; каждая последующая — безопасный no-op. Повторы перестают быть опасными и становятся просто избыточными — а это ровно то, что нужно, ведь повторы и есть то, чем вы страхуетесь от потери данных.
Так что цель — не устранить дубликаты. Цель — сделать дубликаты безвредными. Всё дальнейшее — это техники для этого: дедуп по стабильному id перед мутацией, предпочтение операций, идемпотентных по своей природе, и опора на механизмы дедупа и повторов платформы там, где они реально помогают, — при трезвом понимании, где они не помогают.
Паттерн ключа идемпотентности: дедуп по event id провайдера до мутации
Базовый паттерн для идемпотентного вебхука таков: выберите стабильный ключ идемпотентности, зафиксируйте его до любого побочного эффекта и пропустите работу, если этот ключ уже видели. Лучший ключ почти всегда тот, что провайдер уже вам даёт, — идентификатор события. События Stripe несут evt.id, доставки GitHub несут delivery UUID. Они стабильны между повторами одного логического события — именно это свойство вам и нужно.
Механизм, который делает это безопасным при конкурентности, — условный insert. Две дублирующие доставки могут прийти в одно мгновение на два контейнера; наивная схема «SELECT, потом INSERT» имеет гонку между проверкой и записью. Одиночный INSERT ... ON CONFLICT DO NOTHING атомарен: ровно один из двух конкурентов вставит строку, второй получит ноль затронутых строк и поймёт, что проиграл.
В Inquir сначала проверьте подпись — для Stripe и GitHub шлюз может проверить HMAC по сырому телу за вас, если задать webhookMode на маршруте: он вернёт 403 BAD_SIGNATURE ещё до запуска вашего кода и применит допуск на replay по timestamp для Stripe. Затем дедуп, быстрый ACK — и тяжёлую работу в долговечную очередь:
-- The idempotency ledger. The provider's event id is the primary key,
-- so a duplicate delivery can never insert a second row.
CREATE TABLE processed_events (
event_id text PRIMARY KEY,
provider text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
// webhooks/stripe.mjs — verify, dedup on the event id, then hand off.
export async function handler(event) {
// The gateway already verified the HMAC (route webhookMode: 'stripe').
// body still arrives as a string; parse only AFTER verification.
const evt = JSON.parse(event.body ?? '{}');
// 1. Idempotency key = the provider's own event id. Claim it BEFORE any mutation.
// ON CONFLICT DO NOTHING => a duplicate delivery affects 0 rows.
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'stripe') ON CONFLICT (event_id) DO NOTHING`,
[evt.id],
);
if (rowCount === 0) {
return { statusCode: 200, body: 'duplicate' }; // already seen — safe no-op, still ACK
}
// 2. ACK inside the provider window, then run the heavy work in the durable queue.
// Passing evt.id as the instance id lets the platform dedup this enqueue (24h TTL).
await global.durable.startNew('fulfill-order', evt.id, { eventId: evt.id, type: evt.type });
return { statusCode: 200, body: 'accepted' };
}
Здесь окупаются две вещи. Первая: вы фиксируете ключ до мутации, а не после — поэтому сбой между insert и передачей работы оставит записанный ключ и никакого наполовину применённого эффекта, а повтор провайдера найдёт ключ уже на месте. Вторая: вы возвращаете 200 даже на дубликат — подтверждать дубликат правильно, потому что вы с ним действительно закончили. Ответ с ошибкой лишь вызвал бы ещё один повтор.
Естественная идемпотентность: upsert вместо слепого insert
Реестр выше защищает точку входа. Но задача, которую он ставит в очередь, тоже выполняется на at-least-once очереди — значит, и тело задачи должно переживать повторный запуск. Чище всего сделать саму запись идемпотентной по природе, чтобы дедуплицировать было нечего.
Запись идемпотентна по природе, когда её двукратное выполнение оставляет то же состояние, что и однократное. SET status = 'fulfilled' идемпотентен по природе; balance = balance + 100 — нет. INSERT ... ON CONFLICT DO UPDATE — upsert по бизнес-идентичности — здесь рабочая лошадка: первый запуск вставляет, каждый следующий сходится к той же строке, а не создаёт вторую.
// jobs/fulfill-order.mjs — runs at-least-once, so it MUST be idempotent on its own.
export async function handler(event) {
const { eventId, type } = event.payload ?? {};
// Natural idempotency: upsert on a business key, never a blind INSERT.
// A retry, a visibility-timeout reap, or a provider redelivery all converge
// to the SAME order row instead of creating a duplicate.
await db.query(
`INSERT INTO orders (event_id, status, fulfilled_at)
VALUES ($1, 'fulfilled', now())
ON CONFLICT (event_id) DO UPDATE SET status = 'fulfilled'`,
[eventId],
);
// Side effects that are NOT naturally idempotent (charge a card, send an email)
// need their own guard: check-before-act keyed on eventId, or pass the provider's
// own idempotency key so THEIR system dedups the duplicate for you.
const already = await db.query(
`SELECT 1 FROM sent_receipts WHERE event_id = $1`, [eventId],
);
if (already.rowCount === 0) {
await email.sendReceipt(eventId); // external call: at-most-once effect wanted
await db.query(`INSERT INTO sent_receipts (event_id) VALUES ($1)
ON CONFLICT DO NOTHING`, [eventId]);
}
return { ok: true };
}
Обратите внимание на двухуровневую конструкцию. Состояние, которым владеете вы — строка заказа, — делается идемпотентным по построению через upsert. Побочные эффекты, которыми вы не владеете — списание с карты, отправка письма, — отменить нельзя, поэтому вы страхуете их явной проверкой check-before-act по тому же ключу или, ещё лучше, передаёте собственный ключ идемпотентности провайдера (например, заголовок Idempotency-Key у Stripe), чтобы их система схлопнула ваш дубликат. Сначала тянитесь к естественной идемпотентности; к явному реестру дедупа откатывайтесь только для тех эффектов, которые действительно не выражаются как upsert.
Где помогает платформа: дедуп startNew на 24 часа, повторы и dead-letter-очередь
Inquir даёт три конкретных инструмента, и стоит точно описать каждый, чтобы вы знали его границы.
startNew дедуплицирует постановку в очередь в пределах TTL на 24 часа. Второй аргумент global.durable.startNew(name, id, payload) — это instance id, и он же служит ключом идемпотентности для самой постановки. Если передать стабильный id — event id провайдера идеален, — два одинаковых вызова startNew в окне 24 часа схлопнутся в один экземпляр задачи, а не породят два. Так дубликат доставки, проскользнувший мимо вашего реестра, превращается в дубликат постановки, который платформа поглощает.
Повторы с экспоненциальным backoff доступны — но для обычных задач их надо включить. Обычная фоновая задача по умолчанию имеет maxAttempts: 1, то есть одна попытка и никаких автоповторов, пока вы не поднимете значение. (Внутренний путь возобновления самой платформы использует пять попыток.) Когда вы повторы включаете, неуспешные попытки идут с экспоненциальным backoff, так что нестабильный внешний сервис получает передышку, а не шквал запросов.
Dead-letter-путь ловит «ядовитые» сообщения. Когда задача исчерпывает свой maxAttempts, долговечная очередь отправляет её в dead-letter и записывает последнюю ошибку — так постоянно падающая задача паркуется для разбора и повторного проигрывания, а не теряется молча и не крутится в повторах вечно. Задачи, превысившие visibility timeout, «пожинаются» и снова становятся исполняемыми — ещё одна причина, по которой одно и то же тело задачи может выполниться не один раз.
Вместе это даёт безопасные повторы как режим по умолчанию: реестр на входе останавливает большинство дубликатов, TTL у startNew поглощает часть остального, повторы вытягивают из транзиентных сбоев, а dead-letter-очередь изолирует по-настоящему сломанное. Но ничто из этого не делает ваш обработчик идемпотентным. Эта часть — на вас.
Где идемпотентным обязаны быть вы: обработчики выполняются at-least-once
Вот граница, которую нужно усвоить. 24-часовой дедуп платформы покрывает постановку — сам факт помещения задачи startNew в очередь. Он не покрывает тело задачи. Ваш код обработчика и каждый его побочный эффект не получают от платформы никакой автоматической дедупликации. Если одна и та же задача выполнится дважды — потому что вы включили повторы и первая попытка упала уже после записи, потому что visibility timeout «пожал» медленный прогон, потому что дубликат проскользнул за пределами 24-часового окна, — ваш обработчик снова, с самого начала, выполнит свои побочные эффекты.
Именно поэтому каждый пример выше делает обработчик идемпотентным независимо, а не полагается на то, что очередь запустит его ровно раз. Upsert по orders, проверка check-before-act вокруг письма существуют ровно потому, что платформа — корректно и по замыслу — иногда выполнит обработчик более одного раза. Обработчик, предполагающий однократный запуск, — это отложенное двойное списание, ждущее первого же повтора.
Правило короткое: считайте, что каждый обработчик выполнится минимум дважды, потому что рано или поздно так и будет. Привяжите каждую мутацию к стабильной идентичности. Заставьте своё состояние сходиться через upsert. Страхуйте неотменяемые внешние эффекты явным дедупом или ключом идемпотентности нижестоящего провайдера. Сделайте так — и повтор станет скучным. В этом и весь смысл.
Чего платформа НЕ гарантирует
Честность о гарантиях — это то, что держит ваши данные корректными, поэтому назовём всё прямо:
- Нет exactly-once delivery. Долговечная очередь задач и приём вебхуков — at-least-once. Сообщение может и будет доставлено более одного раза. Режима, который это меняет, нет.
- Нет гарантированного порядка. События не FIFO. Не пишите логику, предполагающую, что событие B обработается после события A только потому, что оно произведено позже; несите версию или timestamp и пусть побеждает более позднее состояние.
- Дедуп
startNewограничен, а не абсолютен. Он дедуплицирует одинаковые постановки только в пределах TTL на 24 часа и только по переданному вами id. Тот же id спустя 24 часа запустит новый экземпляр, и он ничего не говорит о том, сколько раз выполнится тело задачи. - У обработчиков нет автоматической идемпотентности. Очередь дедуплицирует постановку, но никогда — побочные эффекты внутри задачи. Идемпотентная обработка — ваша ответственность, в вашем обработчике.
- Повторы для обычных задач — по включению. Обычная задача по умолчанию делает одну попытку; счётчик повторов и backoff настраиваете вы. Dead-letter и reaping по visibility timeout существуют, но они восстанавливают доставку, а не корректность — задаче, выполнившейся дважды, для безопасности всё равно нужно идемпотентное тело.
Читайте этот список как техзадание на проектирование, а не как дисклеймер. Каждый пункт указывает на один вывод: платформа отвечает за доставку и восстановление; вы отвечаете за идемпотентность.
Вывод: проектируйте под дубликаты, а не против них
Купить exactly-once delivery нельзя, да она вам и не нужна. At-least-once delivery плюс идемпотентный обработчик дают ровно тот эффект, который вам на самом деле нужен — каждое событие применено один раз, — сохраняя при этом повторы, которые не дают терять данные. Выберите стабильный ключ идемпотентности, в идеале event id провайдера. Зафиксируйте его до мутации атомарным INSERT ... ON CONFLICT DO NOTHING. Сделайте собственные записи идемпотентными по природе через upsert, а неотменяемые побочные эффекты страхуйте явным дедупом или ключом идемпотентности нижестоящего провайдера. Пусть Inquir несёт операционный вес — 24-часовой дедуп постановки у startNew, повторы с backoff по включению и dead-letter-очередь, — а вы держите ту единственную гарантию, которую платформа дать не может: обработчик, безопасный для двойного запуска. Постройте так один раз — и каждый повтор, каждая переотправка и каждый дубликат вебхука станут незаметным событием.