Перейти к содержанию

Идемпотентная Activity при потере ответа

Retry помогает пережить временную ошибку, но самый трудный случай возникает после успешного внешнего изменения. Gateway записал публикацию, затем соединение оборвалось. Activity не получила квитанцию и будет повторена. Этот урок показывает, как сделать повтор безопасным, не обещая невозможную универсальную гарантию «весь HTTP выполняется ровно один раз».

Используем Workflow стадии 06-retries и полные общие Activities с gateway.ts. Автор не запускал обрыв ответа и не проверял повтор. В коде есть управляемый учебный режим FAIL_AFTER_COMMIT для будущего наблюдения читателя; его ожидаемые результаты описаны отдельно.

Ключ относится к предметной операции

В contracts.ts функция operationKey строит идентичность:

export function operationKey(input: Submission, operation: string): string {
  return input.submissionId + ':r' + input.revision + ':' + operation;
}

Для article-107, revision 1 и publish получается article-107:r1:publish. Ключ одинаков для всех попыток одной публикации и отличается от reserve или release. В него не входит номер попытки. Если использовать новый ключ при каждом retry, gateway увидит независимые операции и сможет повторить эффект.

Run ID тоже не подходит автоматически для бизнес-идемпотентности. Одну и ту же редакцию могут случайно запустить повторно в другом исполнении. Если предметное правило допускает только одну публикацию этой редакции, ключ должен оставаться связанным с редакцией, а не только с конкретным выполнением Temporal.

Обратная сторона стабильности — нельзя переиспользовать ключ для других данных. Если заголовок изменился, нужно осознанно увеличить revision или получить конфликт. Gateway сохраняет fingerprint из вида операции, идентичности, редакции и title. Повтор с тем же ключом и иным содержимым возвращает 409, не переписывая предыдущий результат.

Типы на стороне Worker не защищают внешний API от всех некорректных payload. Gateway самостоятельно проверяет формат submissionId, номер revision, допустимую операцию и соответствие ключа. Это иллюстрирует границу доверия: устойчивость Workflow не отменяет валидацию HTTP-контракта получателем.

Эффект и квитанция записываются вместе

Учебный gateway хранит три части состояния: effects, reservations и publications. Effects сопоставляет ключ с fingerprint и квитанцией. Когда приходит известный ключ с теми же данными, ответ возвращается из сохранённой записи. Бизнес-изменение повторно не выполняется.

Для новой операции gateway создаёт следующий снимок, меняет предметное состояние и добавляет receipt. Затем сохраняет весь снимок:

writeFileSync(stateFile + '.tmp', JSON.stringify(next, null, 2), 'utf8');
renameSync(stateFile + '.tmp', stateFile);
state = next;

Здесь stateFile и next определены в полном gateway.ts. Запись временного файла и rename показывают идею единого сохранения состояния до ответа. После обычного перезапуска процесса gateway читает этот снимок. Публикация и запись квитанции не расположены в двух независимых асинхронных действиях, между которыми теряется связь.

Но это локальная учебная модель. Один процесс использует синхронный участок изменения; несколько процессов с общей папкой не поддерживаются. Здесь нет fsync-гарантии при потере питания и нет полноценной транзакционной базы. В производственной системе нужен уникальный индекс operation key и транзакция, включающая эффект и запись результата, либо другой согласованный предметный протокол.

Не заменяйте такую связь памятью Worker вида if (!seen.has(key)). После restart память исчезнет; кроме того, внешний эффект мог совершиться до заполнения seen. Проверка принадлежит системе, которая контролирует запись публикации, и должна быть согласована с этой записью. Temporal history не является универсальной транзакцией вашего gateway.

Обрыв после commit

Режим FAIL_AFTER_COMMIT=1 обрывает ответ только после новой publish-операции и сохранения снимка. На повторном обращении сначала проверяется существующая запись effects, и gateway возвращает квитанцию. Таким образом первый запрос может изменить данные, а Activity узнает подтверждение только на следующей попытке.

Будущая демонстрация:

FAIL_AFTER_COMMIT=1 npm run gateway
WORKFLOW_STAGE=06-retries npm run worker
npm run client -- start article-107
npm run client -- signal article-107 approve
npm run client -- result article-107

Ожидаемый предметный итог — одна publication для данной редакции и сохранённая квитанция. В журнале gateway новая операция записывается как committed, повтор лишь возвращает известный результат. Конкретное число попыток не утверждается заранее без запуска. Если timeout настроен иначе, общая граница может влиять на возможность получить подтверждение.

Для нового опыта используйте другой submissionId. Если взять прежнюю заявку, gateway найдёт готовую receipt сразу, и сценарий обрыва новой операции не повторится. Это не случайная поломка демонстрации, а следствие выбранной идемпотентности: известная операция не становится новой от повторного запуска команды.

Receipt в примере равна стабильному ключу. Реальный сервис может возвращать собственный ID публикации, статус и версию результата. Тогда повтор должен вернуть совместимое подтверждение первоначального эффекта. Важно не конкретное оформление строки, а возможность однозначно связать вызов, запись и ответ.

Отказ и повтор тоже требуют договора

Срок хранения квитанций тоже входит в контракт. Если gateway удалит effects раньше возможного повторного обращения, знакомый ключ снова станет выглядеть новым. Для настоящей публикации нужно согласовать retention с жизненным циклом заявок, повторами и операционным восстановлением. В локальном примере квитанции автоматически не удаляются.

Release удаляет бронь, но не удаляет уже опубликованный материал. Повтор release возвращает прежний результат без вреда; release после publish не выполняет автоматический отзыв. Это предметная граница компенсации, которую позднее нужно учитывать при cancel. Идемпотентность не означает обратимость.

Reserve с тем же ключом также не создаёт новую бронь после завершённого цикла. Если материал требуется согласовать заново, начинается новая revision. Протокол ключей задаёт жизненный цикл операций, поэтому его нельзя проектировать отдельно от состояний заявки.

Если внешний сервис не поддерживает idempotency key, доступны другие решения: поиск созданного объекта по естественному уникальному ID, предметная дедупликация в вашей базе, outbox и согласование через отдельный сервис. Но нельзя обещать безопасность произвольного POST только потому, что его вызывает Activity. Граница внешнего эффекта должна быть решена явно.

Temporal описывает Activities как область внешних действий; параметры и API приведены в справке Activity. Наш результат выходит за синтаксис SDK: publication и receipt согласованы одной системой, ключ стабилен при retry, конфликт данных видим. Далее рассмотрим отмену Workflow и прерывание I/O, где уже совершённый эффект тоже нельзя считать исчезнувшим.

Оглавление серии