Skip to content

Ошибки и компенсация

Компенсация — отдельная операция, которая отменяет бизнес-эффект уже успешного node, если следующий node завершился ошибкой.

Это не database transaction. Frontend вызывает реальные backend endpoints: reserve и затем release.

Пример checkout

ts
// Каждый handler по-прежнему является Query Layer store.
const reserveStore = new MutationStore<Reservation, [ReserveInput]>({
  mutationKey: "checkout.reserve",
  request: (input, config) => checkoutApi.reserve(input, config),
  options: { retry: false },
})

const releaseStore = new MutationStore<void, [string]>({
  mutationKey: "checkout.release",
  request: (reservationId, config) => {
    return checkoutApi.release(reservationId, config)
  },
  options: { retry: false },
})

const paymentStore = new MutationStore<Payment, [PaymentInput]>({
  mutationKey: "checkout.pay",
  request: (input, config) => checkoutApi.pay(input, config),
  options: { retry: false },
})

Workflow:

ts
const checkoutWorkflow = new WorkflowStore({
  nodes: [
    {
      id: "reserve",

      // Сначала резервируем товар.
      execute: async () => {
        const response = await reserveStore.execute(reserveInput)

        if (!response) {
          throw new Error("Reservation was cancelled")
        }

        return response.data
      },

      // Если следующий node упал, снимаем созданный резерв.
      compensate: async (value) => {
        const reservation = value as Reservation
        await releaseStore.execute(reservation.id)
      },
    },
    {
      id: "payment",
      dependencies: ["reserve"],

      execute: async ({ results }) => {
        const reservation = results.get("reserve") as Reservation

        const response = await paymentStore.execute({
          reservationId: reservation.id,
          paymentMethodId,
          idempotencyKey: crypto.randomUUID(),
        })

        if (!response) {
          throw new Error("Payment was cancelled")
        }

        return response.data
      },
    },
  ],
})

Если payment упал:

  1. workflow запоминает failed node payment;
  2. берёт успешно completed nodes в обратном порядке;
  3. вызывает reserve.compensate(reservation);
  4. собирает ошибки compensation;
  5. бросает WorkflowExecutionError.

Обратный порядок

Для цепочки:

text
create-order → reserve-stock → charge-payment → send-email

при ошибке send-email compensation выполняется обратно:

text
refund-payment → release-stock → cancel-order

Компенсируются только nodes, у которых есть compensate и которые успели успешно завершиться.

На параллельном уровне обратный порядок соответствует фактическому порядку завершения, а не порядку объявления массива.

WorkflowExecutionError

ts
import {
  WorkflowExecutionError,
} from "@dubium/query-layer/orchestration"

try {
  await checkoutWorkflow.execute()
} catch (error) {
  if (error instanceof WorkflowExecutionError) {
    error.failedNodeId
    error.cause
    error.compensationErrors
  }
}
ПолеЧто содержит
failedNodeIdid node, где произошла исходная ошибка.
causeИсходную ошибку condition/execute/cancellation.
compensationErrorsВсе ошибки обратных операций; compensation не останавливается после первой.

WorkflowStore.error содержит тот же error object.

Ошибка compensation

Ошибка release/refund не скрывает исходную ошибку payment. Она добавляется в compensationErrors. Product должен предусмотреть:

  • alert/monitoring;
  • ручное восстановление оператором;
  • retry идемпотентной compensation;
  • reconciliation job backend.

Не показывайте пользователю только «payment failed», если резерв снять не удалось.

Compensation должна быть идемпотентной

Checkpoint/reload или нестабильная сеть могут привести к повторному вызову. Backend compensation endpoint должен безопасно отвечать на повтор:

text
DELETE /reservations/:id

может вернуть success, даже если резерв уже снят, либо использовать idempotency key.

Cancellation

workflow.cancel() тоже приводит к error path внутреннего executor. Уже завершённые nodes могут быть компенсированы. WorkflowStore.status станет cancelled.

Активный HTTP внутри node нужно связать с workflow signal через adapter, показанный на странице Узлы и зависимости.

Что не компенсировать

Не каждому read node нужна компенсация:

ts
{
  id: "profile",
  execute: () => profileStore.fetch(),
}

GET не изменил server state. Compensation нужна только для успешно выполненных side effects.

Email/push notification часто невозможно «отправить назад». Проектируйте workflow так, чтобы необратимые nodes шли после критичных компенсируемых шагов, либо переносите orchestration в backend transaction/saga.

Когда frontend compensation не подходит

Выбирайте backend orchestration, если:

  • операция должна продолжиться после закрытия браузера;
  • требуется строгая финансовая консистентность;
  • несколько сервисов должны договориться атомарно;
  • secrets доступны только backend;
  • workflow длится минуты/часы;
  • audit trail обязателен независимо от клиента.

Frontend WorkflowStore полезен для UI-driven процесса, но не заменяет backend saga.