Повтор — нормальный режим распределённой системы
Worker может выполнить действие, но упасть до записи успеха. Клиент увидит timeout и повторит запрос. Оркестратор тоже повторит activity. Если действие — генерация черновика, вы получите лишние токены; если это письмо, публикация или платёж — реальный ущерб. Поэтому каждый side effect проектируют для повторного исполнения.
Выберите границу бизнес-операции
Ключ должен означать то, что пользователь считает одной операцией: invoice_id + charge, campaign_id + recipient_id + send, document_id + version + publish. Случайный UUID, генерируемый при каждом retry, бесполезен: он делает повторы разными операциями.
Таблица идемпотентности
Создайте запись с уникальным индексом по (tenant_id, operation, key). Храните hash нормализованного payload, статус, attempt, external_reference, response snapshot и expiry. Если тот же ключ пришёл с другим payload, возвращайте конфликт — молча применять старый результат опасно.
BEGIN;
INSERT INTO operations(key, payload_hash, status)
VALUES (:key, :hash, 'started')
ON CONFLICT DO NOTHING;
-- победитель выполняет следующий шаг
COMMIT;
Inbox и transactional outbox
Inbox дедуплицирует входящие события. Outbox решает разрыв между записью состояния и отправкой команды: бизнес-транзакция сохраняет изменение и событие outbox вместе, а отдельный publisher доставляет событие с повторами. Получатель всё равно обязан дедуплицировать его — outbox убирает потерю, но не обещает отсутствие повторов.
Состояние «исход неизвестен»
Самый сложный случай: внешний API принял команду, но ответ потерялся. Нельзя автоматически повторять необратимое действие. Сначала запросите состояние по idempotency key или external reference. Если провайдер не поддерживает поиск и ключи, добавьте ручное подтверждение либо reconciliation job.
Checkpoint для многошагового агента
Не делайте один ключ на весь длинный workflow. У каждого эффекта свой стабильный step key, а состояние workflow хранит завершённые шаги и их артефакты. После перезапуска агент перечитывает checkpoint и продолжает с первого незавершённого шага, не просит модель заново принимать уже исполненное решение.
Что нельзя считать защитой
- локальный флаг в памяти исчезает при рестарте;
- проверка «есть ли объект» перед созданием создаёт race condition;
- mutex одного процесса не защищает от второго worker;
- низкая вероятность дубля не делает платёж безопасным.
Нужна уникальность, гарантированная общим хранилищем или целевым сервисом.
Тесты, которые находят настоящие ошибки
Инъецируйте падение после внешнего успеха, но до локального commit; дважды доставляйте одно сообщение параллельно; повторяйте запрос после истечения TTL; меняйте payload при том же ключе. Инвариант теста формулируйте по бизнесу: списание одно, письмо одно, опубликованная версия одна.
Жизненный цикл записи операции
Статусы должны иметь строгие переходы: reserved → executing → succeeded, а также failed_retryable, failed_terminal и unknown. Владелец lease обновляет heartbeat; другой worker может забрать просроченную запись, но сначала обязан выполнить reconciliation. Ответ успешной операции кэшируется и возвращается всем повторам с тем же ключом.
TTL и повторное использование ключа
Удалять ключ сразу после ответа опасно: запоздавшее сообщение снова выполнит действие. TTL выбирают по максимальному времени доставки, сроку retry и бизнес-риску. Для платежа или публикации запись может храниться столько же, сколько сама сущность. Если storage дорог, оставьте компактный tombstone с hash payload и external reference.
Компенсация не равна откату
Распределённую цепочку нельзя откатить как одну транзакцию. Вместо этого определите компенсирующее действие: отменить бронь, создать возврат, снять публикацию. Компенсация сама является идемпотентной операцией с отдельным ключом и может завершиться ошибкой. Пользователю показывают фактическое состояние, а не фиктивное «всё отменено».