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

Компенсация брони и ручное согласование ошибки

Отмена предыдущего урока освобождала бронь, но обычная окончательная ошибка публикации оставалась без такого пути. Теперь распространим cleanup на неуспешное завершение операций. Результат урока — выразить компенсацию как предметное действие с собственными пределами, а не как волшебный rollback распределённой транзакции.

Полный Workflow лежит в 09-compensation.ts. Он сохраняет модель Signal и срока, использует безопасный releaseSlot и отдельно сообщает ManualIntervention, если cleanup не подтверждён. Автор не выполнял ошибки или компенсации. Ожидаемые случаи ниже нужны для будущего самостоятельного разбора локальной лаборатории.

Что именно компенсируем

ReserveSlot создаёт бронь. Publish создаёт publication и потребляет бронь. ReleaseSlot убирает бронь, если она есть. Из этой модели следует ограничение: release не отзывает публикацию. Значит, компенсация имеет определённую область действия, и интерфейс не должен обещать полное возвращение внешней системы в исходное состояние.

Для rejected и expired release является обычной предметной веткой. Для сбоя после возможной брони он становится cleanup. Код действия одинаков, но причина различается. Такая организация полезна: идемпотентность release позволяет повторять освобождение независимо от того, успел ли Worker получить подтверждение предыдущего обращения.

Нельзя просто восстановить локальную переменную reserved=false и считать внешний ресурс свободным. Переменная существует в Workflow-модели; реальная бронь принадлежит gateway. Поэтому компенсация выполняется Activity и получает подтверждение от той системы, которая контролирует ресурс. Temporal history затем фиксирует результат этого обращения.

Для нескольких ресурсов компенсации обычно выполняют в обратном порядке зависимости: сначала последствия последнего шага, затем предыдущего. Однако порядок определяется предметным смыслом, а не только стеком функций. В «Согласовании» ресурс один, поэтому используем один явный признак вместо универсального фреймворка, скрывающего важные детали.

Возможный эффект до подтверждения

В начале Workflow задаётся releaseMayBeNeeded = true, до вызова reserveSlot. Это учитывает потерю ответа. Gateway мог создать бронь, а Activity ещё не получила receipt. Если регистрировать cleanup только после успешного await reserveSlot, такая неопределённая бронь осталась бы без освобождения.

Выбранный release безопасен при отсутствии ресурса, поэтому ранняя регистрация допустима. Если внешняя компенсация требует точно существующий объект или сама создаёт иной эффект, понадобится более сложный протокол. Нельзя автоматически переносить правило «зарегистрируйте cleanup заранее» на любую удалённую систему без изучения её контракта.

После подтверждённой publish признак становится false: бронь уже потреблена публикацией. При rejected или expired сначала подтверждается release, затем снимается необходимость повторной очистки. Таким образом Workflow хранит не произвольный статус для красоты, а сведения о том, какая внешняя обязанность ещё может оставаться.

В полном файле catch использует:

if (releaseMayBeNeeded) {
  status = 'compensating';
  try {
    await CancellationScope.nonCancellable(() => releaseSlot(input));
  } catch {
    throw ApplicationFailure.nonRetryable(
      'Reservation requires manual reconciliation',
      'ManualIntervention',
      { submissionId: input.submissionId, revision: input.revision },
    );
  }
}
throw error;

Если cleanup успешен, выбрасывается первоначальная ошибка. Пользователь не получает invented published. Если cleanup не подтвердился, Workflow выражает ManualIntervention с идентичностью заявки. Это не доказательство, что ресурс всё ещё занят: мог потеряться и ответ освобождения. Тип обозначает необходимость согласовать наблюдаемое состояние.

Компенсация тоже может не выполниться

Non-cancellable область защищает от исходной отмены, но не от недоступного gateway, конфликта данных или исчерпанного timeout. ReleaseSlot использует ту же ограниченную политику попыток. Поэтому компенсацию нельзя считать безусловно надёжной только потому, что она находится в catch.

Для настоящего продукта следует определить владельца ManualIntervention. Кто прочитает внешний статус, какие права нужны для исправления, как фиксируется решение и как пользователь узнает итог? Без такого регламента явная ошибка технически существует, а предметная обязанность остаётся ничьей.

Наш CLI не реализует отдельный интерфейс оператора. Handoff предписывает сверить reservations, publications и effects по стабильному ключу. Учебный JSON-файл читается только в локальном окружении; в production подобная сверка обычно выполняется через API и журнал операций. Не следует менять произвольные поля базы вручную без предметной процедуры.

Сохранение первоначальной причины также важно для расследования. В простом примере ManualIntervention заменяет финальный тип и содержит идентичность, а предыдущие события остаются в истории. Полноценная система может включить безопасные структурированные сведения об обеих причинах. Она не должна записывать секреты в детали ошибки ради удобства отладки.

Ошибка во время публикации

Представим, publish записала publication, но все доступные попытки получения receipt закончились неудачно. Workflow видит окончательную ошибку и вызывает releaseSlot. Gateway больше не содержит бронь, поэтому release безопасно подтверждается. Однако publication остаётся. Итог Workflow failed не равен отсутствию опубликованного материала.

Этот пример показывает границу распределённой компенсации. Когда эффект необратим или требует отдельного процесса, нужен дополнительный статус «публикация требует сверки» либо отдельная Activity отзыва. Автоматическое удаление материала тоже может быть неверным: его уже прочитали пользователи или использует другой процесс.

В нашем учебном режиме FAIL_AFTER_COMMIT повтор обычно получает сохранённую receipt, поэтому такой сложный финал не обязательно возникает. Для урока важен сам класс неопределённости. Здесь нет утверждения, что автор искусственно исчерпал все попытки и наблюдал конкретную запись. Доказательство требует последующей самостоятельной проверки.

Ошибка Workflow-кода, вызывающая несовместимое воспроизведение, отличается от ожидаемой ActivityFailure. Catch не превращает любой nondeterminism в успешную компенсацию. Если Worker не может продвинуть Workflow Task, cleanup также может не быть запланирован. Поэтому совместимость кода — отдельная операционная обязанность.

Когда saga полезна для веб-процесса

Если процесс занимает слот, списывает резерв и создаёт публикацию в разных системах, общая транзакция часто отсутствует. Можно описать шаги и предметные способы устранить последствия предыдущих шагов. Такой подход обычно называют saga. Его цель — достигнуть согласованного результата через определённые действия, а не притвориться атомарной SQL-транзакцией через сеть.

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

Синтаксис CancellationScope описан в документации scopes, а ApplicationFailure — в API common. Наш предметный результат — известная возможная обязанность, безопасная компенсация и явный неподтверждённый исход.

Далее разберём историю и replay. Они помогут выяснить, какие решения действительно записаны, и почему исправление кода должно сохранять совместимость с уже начатыми процессами.

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