LESSON 40分

ストーリー

あなた
Sagaで失敗したら補償するのはわかりました。でも、“返金”ってそんな簡単にできるんですか? 元に戻せない操作もありますよね?
高橋アーキテクト
良い質問だ。補償はロールバックとは違う。“元に戻す”のではなく、“ビジネス的に打ち消す”操作を行うんだ。そしてそれは必ずしも簡単ではない

補償トランザクションとは

データベースのロールバックと異なり、ビジネスロジックとして「打ち消す操作」を実行することです。

データベースロールバック:
  INSERT → 物理的に行を削除(なかったことにする)

補償トランザクション:
  注文作成 → 注文をキャンセル状態に更新(履歴は残る)
  決済完了 → 返金処理を実行(決済履歴 + 返金履歴が残る)
  在庫確保 → 在庫を解放(確保履歴 + 解放履歴が残る)

補償の設計原則

1. すべてのステップに補償を定義

// 各Sagaステップに対応する補償を必ず定義
interface SagaStepDefinition {
  name: string;
  execute: (ctx: SagaContext) => Promise<StepResult>;
  compensate: (ctx: SagaContext) => Promise<void>;
  // 補償が不要なステップ(読み取り専用など)はnoopを設定
}

const orderSagaSteps: SagaStepDefinition[] = [
  {
    name: "createOrder",
    execute: async (ctx) => {
      const order = await orderService.create(ctx.orderData);
      return { orderId: order.id };
    },
    compensate: async (ctx) => {
      await orderService.cancel(ctx.orderId);
      // 注文をCANCELLED状態に更新(削除ではない)
    },
  },
  {
    name: "processPayment",
    execute: async (ctx) => {
      const payment = await paymentService.charge({
        orderId: ctx.orderId,
        amount: ctx.amount,
      });
      return { paymentId: payment.id };
    },
    compensate: async (ctx) => {
      await paymentService.refund(ctx.paymentId);
      // 返金処理を実行(決済の取り消しではなく新たな返金)
    },
  },
  {
    name: "reserveInventory",
    execute: async (ctx) => {
      await inventoryService.reserve(ctx.orderId, ctx.items);
      return {};
    },
    compensate: async (ctx) => {
      await inventoryService.release(ctx.orderId);
      // 確保した在庫を解放
    },
  },
];

2. 補償の冪等性

補償トランザクション自体も冪等でなければなりません。

class PaymentService {
  async refund(paymentId: string): Promise<void> {
    const payment = await this.paymentRepo.findById(paymentId);

    // 冪等性: 既に返金済みならスキップ
    if (payment.status === "REFUNDED") {
      console.log(`Payment ${paymentId} already refunded`);
      return;
    }

    // 返金処理
    await this.paymentGateway.refund(payment.transactionId, payment.amount);
    await this.paymentRepo.updateStatus(paymentId, "REFUNDED");
  }
}

3. 補償不可能な操作への対処

// 物理的に元に戻せない操作の例
const irreversibleActions = {
  // メール送信 → 送信済みメールは取り消せない
  emailSent: {
    compensation: "お詫びメールを送信する",
    // または: メール送信をSagaの最後のステップにして、
    //         失敗リスクを最小化する
  },

  // 外部API呼び出し → 相手側の状態変更は取り消せない場合がある
  externalApiCall: {
    compensation: "相手のキャンセルAPIを呼ぶ(あれば)",
    fallback: "手動対応のアラートを発報",
  },

  // SMS送信 → 送信済みは取り消せない
  smsSent: {
    compensation: "キャンセル通知のSMSを送る",
  },
};

// 対策: 不可逆な操作はSagaの最後に配置
const reorderedSteps = [
  "createOrder",       // 可逆
  "processPayment",    // 可逆(返金可能)
  "reserveInventory",  // 可逆(解放可能)
  "createShipment",    // 可逆(出荷前なら)
  "sendNotification",  // 不可逆 → 最後に配置
];

補償の失敗への対処

補償自体が失敗する場合の戦略です。

class SagaCompensator {
  async compensateWithRetry(
    step: SagaStepDefinition,
    context: SagaContext,
    maxRetries: number = 5
  ): Promise<void> {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        await step.compensate(context);
        console.log(`Compensation "${step.name}" succeeded on attempt ${attempt}`);
        return;
      } catch (error) {
        console.error(`Compensation "${step.name}" failed (attempt ${attempt}):`, error);

        if (attempt === maxRetries) {
          // 最大リトライ回数に達した → 手動対応へエスカレーション
          await this.escalateToManual(step, context, error);
          return;
        }

        // 指数バックオフで待機
        await sleep(Math.pow(2, attempt) * 1000);
      }
    }
  }

  private async escalateToManual(
    step: SagaStepDefinition,
    context: SagaContext,
    error: Error
  ): Promise<void> {
    // 運用チームに通知
    await alertService.sendCritical({
      title: `Saga補償失敗: ${step.name}`,
      sagaId: context.sagaId,
      context: JSON.stringify(context),
      error: error.message,
      action: "手動での補償処理が必要です",
    });

    // 補償失敗をDBに記録(後で手動リカバリ)
    await sagaRepo.markCompensationFailed(context.sagaId, step.name);
  }
}

セマンティックロック

Sagaの中間状態を管理するカウンターメジャーです。

// セマンティックロック: 中間状態をフラグで管理
interface Order {
  id: string;
  status: "APPROVAL_PENDING" | "APPROVED" | "REJECTED";
  // APPROVAL_PENDING = Sagaが進行中
  // → 他の操作(変更、キャンセル)をブロック

  sagaStatus: "IN_PROGRESS" | "COMPLETED" | "COMPENSATING";
}

// 別のリクエストが注文を変更しようとした場合
async function modifyOrder(orderId: string, changes: OrderChanges): Promise<void> {
  const order = await orderRepo.findById(orderId);

  if (order.sagaStatus === "IN_PROGRESS") {
    throw new ConflictError(
      "注文はSaga処理中のため変更できません。しばらくお待ちください。"
    );
  }

  await orderRepo.update(orderId, changes);
}

まとめ

ポイント内容
補償 vs ロールバックビジネス的な打ち消し操作(履歴は残る)
設計原則全ステップに補償定義、補償も冪等に
不可逆な操作Sagaの最後に配置して失敗リスクを最小化
補償の失敗リトライ → 手動エスカレーション
セマンティックロック中間状態のフラグで並行操作を制御

チェックリスト

  • 補償トランザクションとDBロールバックの違いを説明できる
  • 補償の冪等性が必要な理由を理解した
  • 不可逆な操作の配置戦略を説明できる
  • 補償失敗時のエスカレーション戦略を理解した

次のステップへ

次はOutboxパターンを学びます。「DBへの書き込み」と「イベントの発行」を確実に両方成功させる技術です。


推定読了時間: 40分