LESSON 40分

ストーリー

佐藤CTO
補償は単なるロールバックではない。ビジネスの文脈で”正しい逆操作”を設計することが求められる

Saga ステップの3分類

分類特徴
Compensatable補償可能、ピボット前に実行在庫引当、仮予約
Pivot補償不可能、成功=前進確定外部決済API課金
Retriable必ず成功する(リトライで)メール送信、ログ記録

実行順序の設計

graph LR
    A["Compensatable<br/>在庫引当"] --> B["Compensatable<br/>仮予約"] --> C["Pivot<br/>決済処理"] --> D["Retriable<br/>出荷指示"] --> E["Retriable<br/>通知送信"]

    style A fill:#fff3e0,stroke:#e65100
    style B fill:#fff3e0,stroke:#e65100
    style C fill:#ffebee,stroke:#c62828
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#e8f5e9,stroke:#2e7d32
失敗パターン:
① 在庫引当で失敗 → 即キャンセル(補償不要)
② 仮予約で失敗 → 在庫引当解除(1つ補償)
③ 決済で失敗 → 仮予約キャンセル + 在庫解除(2つ補償)
④ 出荷で失敗 → リトライで必ず成功させる(補償なし)

補償設計のパターン

パターン1: 状態遷移による補償

// 注文のライフサイクル
enum OrderStatus {
  CREATED = 'CREATED',       // 作成
  STOCK_RESERVED = 'RESERVED', // 在庫引当済み
  PAID = 'PAID',             // 決済済み
  SHIPPED = 'SHIPPED',       // 出荷済み
  DELIVERED = 'DELIVERED',   // 配送完了
  CANCELLED = 'CANCELLED',   // キャンセル
  REFUNDED = 'REFUNDED',     // 返金済み
}

// 補償 = 逆方向の状態遷移
const compensationMap: Record<string, string> = {
  'RESERVED': 'CREATED',    // 引当解除
  'PAID': 'REFUNDING',      // 返金処理中
  'SHIPPED': 'RETURN_REQUESTED', // 返品受付
};

パターン2: 金額の補償

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

    if (payment.status === 'CHARGED') {
      // 全額返金(新しいトランザクションとして記録)
      const refund = await this.stripeClient.refunds.create({
        payment_intent: payment.externalId,
        amount: payment.amount,
      });

      await this.paymentRepo.save({
        ...payment,
        status: 'REFUNDED',
        refundId: refund.id,
        refundedAt: new Date(),
      });
    }
    // 既にREFUNDED → 冪等(何もしない)
  }
}

パターン3: タイムアウト付き仮予約

class StockReservation {
  async reserve(orderId: string, items: Item[]): Promise<Reservation> {
    const reservation = await this.reservationRepo.create({
      orderId,
      items,
      status: 'ACTIVE',
      expiresAt: addMinutes(new Date(), 15), // 15分で自動解除
    });
    return reservation;
  }

  // 定期バッチ: 期限切れの仮予約を自動解除
  async releaseExpired(): Promise<void> {
    const expired = await this.reservationRepo.findExpired();
    for (const res of expired) {
      await this.release(res.id);
    }
  }
}

補償失敗の対処

補償自体が失敗した場合の戦略:

戦略説明適用場面
リトライ指数バックオフで再試行一時的な障害
Dead Letter Queue失敗メッセージを別キューへ手動対応が必要
アラート運用チームに通知緊急対応が必要
定期バッチ不整合を定期的にスキャン・修正最終防衛線
async function compensateWithRetry(
  action: () => Promise<void>,
  maxRetries: number = 3
): Promise<void> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await action();
      return;
    } catch (error) {
      if (attempt === maxRetries) {
        // DLQに送信 + アラート
        await deadLetterQueue.send({ action: action.name, error });
        await alertService.critical('Compensation failed permanently');
        return;
      }
      await sleep(Math.pow(2, attempt) * 1000); // 指数バックオフ
    }
  }
}

まとめ

ポイント内容
3分類Compensatable → Pivot → Retriable の順で設計
状態遷移逆方向の遷移として補償を定義
タイムアウト仮予約は自動解除で安全性確保
補償失敗リトライ→DLQ→アラート→バッチの多層防御

チェックリスト

  • 3分類(Compensatable/Pivot/Retriable)を説明できる
  • 補償の実装パターンを理解した
  • タイムアウト付き仮予約の設計を理解した
  • 補償失敗時の対処戦略を説明できる

次のステップへ

次は Transactional Outbox パターンを学びます。


推定読了時間: 40分