Claude Media
Claude Codeでサーガパターンの補償をテストする — 失敗位置を総当たり

Claude Codeでサーガパターンの補償をテストする — 失敗位置を総当たり

サーガの各ステップに失敗を注入し、補償が逆順で冪等かをClaude Codeにテストさせる手順です。Temporalなどのフレームワークを使わない実装で、手元の試験結果つきで示します。

Claude Codeでサーガパターンの補償をテストする — 失敗位置を総当たり

サーガ(Saga)は、複数サービスにまたがる処理を「ローカルなトランザクションの列」として進め、途中で失敗したら補償トランザクションで元に戻すパターンです。正常系は誰が書いても動きます。壊れるのは失敗経路で、しかも「どのステップで落ちたか」によって後始末の内容が変わります。

この記事では、Claude Codeにサーガの実装とテストを書かせ、全ステップ × 失敗の種類を総当たりで注入して、補償が逆順に走り、何度流しても同じ結果になるかを確かめます。フレームワークは使いません。Temporalでの実装はClaude CodeでTemporalのワークフローを書くにあり、ここではその手前にある素のTypeScriptのサーガが対象です。

サーガの補償で何を検証するか

サーガは、各ステップが自分のDBに対するローカルトランザクションで、全体を貫くACIDトランザクションはありません。microservices.ioのSagaパターン解説は、欠点として「自動ロールバックがないこと」を挙げ、開発者が前のステップの変更を明示的に取り消す補償トランザクションを設計する必要があると説明しています。つまり、補償は自分で書いた通常のコードであり、バグも入ります。

Azureのアーキテクチャガイドの補償トランザクションの節は、設計上の注意を次の形で書いています。テストの観点はここから直接取れます。

設計上の注意テストで確かめること
補償自体も失敗しうる。再試行されるので冪等なコマンドにするテストで確かめること補償を2回流しても状態が変わらない
失敗後に元の状態と同一へ戻るとは限らず、成功した分を打ち消すテストで確かめること各ステップの副作用が残っていない
補償は元の操作と厳密に逆順とは限らないテストで確かめること順序を仕様として決め、テストに固定する
補償の進捗を記録し、失敗地点から再開できるテストで確かめること補償が一度失敗しても最終的に収束する

「順序は逆とは限らない」は見落としやすい点です。整合性に敏感なストアを先に戻す設計もありえます。この記事のサンプルは単純に逆順としますが、先に戻す順番が業務上決まっているなら、それを期待値としてテストに書きます。

失敗位置の総当たり表を先に作る

失敗の注入点は「ステップ × タイミング」で数えます。タイミングは2種類です。

  • before: 副作用が起きる前に失敗する(接続拒否など)
  • after: 副作用は成功したのに、応答だけ失敗する(タイムアウトなど)

after が本題です。呼び出し側からは失敗に見えますが、相手側では課金も在庫引当も済んでいます。このとき失敗したステップ自身も補償しないと、状態が残ります。在庫引当・課金・出荷ラベル発行の3ステップなら、注入点は6つです。

注入点完了済みで補償が要るステップ
reserve/before完了済みで補償が要るステップreserve(未実行だが呼ぶ。何もしないこと)
reserve/after完了済みで補償が要るステップreserve
charge/before完了済みで補償が要るステップreserve、charge(未実行)
charge/after完了済みで補償が要るステップreserve、charge
label/before完了済みで補償が要るステップreserve、charge、label(未実行)
label/after完了済みで補償が要るステップreserve、charge、label

「未実行のステップにも補償が飛ぶ」点が、冪等性の条件を厳しくします。補償は「やったことを戻す」だけでなく「やっていなければ何もしない」も満たす必要があります。

サーガ本体とフォールトインジェクション

サンプルは3ファイルです。まずオーケストレーターで、失敗したステップも補償対象に含め、補償は逆順に再試行つきで流します。

export type Step = {
  name: string;
  run: (orderId: string) => Promise<void>;
  compensate: (orderId: string) => Promise<void>;
};
 
export type SagaResult =
  | { status: "completed" }
  | { status: "compensated"; failedStep: string }
  | { status: "stuck"; failedStep: string; stuckStep: string };
 
export async function runSaga(
  steps: Step[],
  orderId: string,
  log: string[] = [],
  maxCompensateAttempts = 3,
): Promise<SagaResult> {
  const attempted: Step[] = [];
  for (const step of steps) {
    attempted.push(step); // 失敗した step も補償対象に含める
    try {
      log.push(`run:${step.name}`);
      await step.run(orderId);
    } catch {
      for (const done of [...attempted].reverse()) {
        let ok = false;
        for (let i = 0; i < maxCompensateAttempts && !ok; i++) {
          try {
            log.push(`compensate:${done.name}`);
            await done.compensate(orderId);
            ok = true;
          } catch {
            /* 同じ補償を再試行する */
          }
        }
        if (!ok) {
          return { status: "stuck", failedStep: step.name, stuckStep: done.name };
        }
      }
      return { status: "compensated", failedStep: step.name };
    }
  }
  return { status: "completed" };
}

次に、在庫・課金・出荷を模したインメモリのサービスです。副作用は orderId をキーに冪等に書いてあり、createWorld の引数で失敗位置と「補償が一度失敗するステップ」を指定します。

import type { Step } from "./saga.ts";
 
export type FaultPoint = { step: string; when: "before" | "after" };
 
export function createWorld(
  fault?: FaultPoint,
  compensateFailsOnce?: string,
) {
  const state = {
    stock: 5,
    reservations: new Set<string>(),
    charges: new Map<string, number>(), // orderId -> 正味の請求額
    labels: new Set<string>(),
  };
  const failedCompensations = new Set<string>();
 
  // after: 副作用は成功したのに応答だけ失敗した状況(タイムアウト等)
  async function withFault<T>(name: string, effect: () => T): Promise<void> {
    const hit = fault?.step === name ? fault.when : undefined;
    if (hit === "before") throw new Error(`injected:${name}:before`);
    effect();
    if (hit === "after") throw new Error(`injected:${name}:after`);
  }
 
  async function failOnceIfAsked(name: string) {
    if (compensateFailsOnce === name && !failedCompensations.has(name)) {
      failedCompensations.add(name);
      throw new Error(`compensation-failed:${name}`);
    }
  }
 
  const steps: Step[] = [
    {
      name: "reserve",
      run: (id) => withFault("reserve", () => {
        if (state.reservations.has(id)) return; // 冪等
        state.reservations.add(id);
        state.stock -= 1;
      }),
      compensate: async (id) => {
        await failOnceIfAsked("reserve");
        if (!state.reservations.delete(id)) return; // 無ければ何もしない
        state.stock += 1;
      },
    },
    {
      name: "charge",
      run: (id) => withFault("charge", () => {
        if (state.charges.has(id)) return;
        state.charges.set(id, 3000);
      }),
      compensate: async (id) => {
        await failOnceIfAsked("charge");
        if (!state.charges.has(id)) return;
        state.charges.set(id, 0); // 返金済み。2回目も結果は同じ
      },
    },
    {
      name: "label",
      run: (id) => withFault("label", () => { state.labels.add(id); }),
      compensate: async (id) => {
        await failOnceIfAsked("label");
        state.labels.delete(id);
      },
    },
  ];
  return { state, steps };
}

最後に、6つの注入点をループで回すテストです。1つの注入点につき2本のテストを作ります。1本目は「完了後に全状態が初期値へ戻り、補償の順序が実行順の逆になっている」こと、2本目は「補償をもう一度全部流しても状態が変わらない」ことを見ます。

import { test } from "node:test";
import assert from "node:assert/strict";
import { runSaga } from "./saga.ts";
import { createWorld, type FaultPoint } from "./services.ts";
 
const names = ["reserve", "charge", "label"];
const points: FaultPoint[] = names.flatMap((step) => [
  { step, when: "before" as const },
  { step, when: "after" as const },
]);
 
type World = ReturnType<typeof createWorld>;
 
function snapshot(w: World) {
  const { stock, reservations, charges, labels } = w.state;
  const rows = [stock, [...reservations], [...charges], [...labels]];
  return JSON.stringify(rows);
}
 
function netCharge(w: World) {
  return [...w.state.charges.values()].reduce((a, b) => a + b, 0);
}
 
const verbs = (log: string[], prefix: string) =>
  log.filter((l) => l.startsWith(prefix)).map((l) => l.slice(prefix.length));
 
for (const fault of points) {
  const label = `${fault.step}/${fault.when}`;
 
  test(`失敗注入 ${label}: 元に戻り、補償は逆順`, async () => {
    const w = createWorld(fault);
    const log: string[] = [];
    const result = await runSaga(w.steps, "o-1", log);
    assert.equal(result.status, "compensated");
    assert.equal(w.state.stock, 5);
    assert.equal(w.state.reservations.size, 0);
    assert.equal(netCharge(w), 0);
    assert.equal(w.state.labels.size, 0);
    const ran = verbs(log, "run:");
    assert.deepEqual(verbs(log, "compensate:"), [...ran].reverse());
  });
 
  test(`冪等性 ${label}: 補償を再実行しても同じ`, async () => {
    const w = createWorld(fault);
    await runSaga(w.steps, "o-1");
    const before = snapshot(w);
    for (const s of [...w.steps].reverse()) await s.compensate("o-1");
    assert.equal(snapshot(w), before);
  });
}
 
test("補償が一度失敗しても再試行で収束する", async () => {
  const w = createWorld({ step: "label", when: "before" }, "charge");
  const log: string[] = [];
  const result = await runSaga(w.steps, "o-1", log);
  assert.equal(result.status, "compensated");
  assert.equal(netCharge(w), 0);
  assert.equal(verbs(log, "compensate:").filter((n) => n === "charge").length, 2);
});
 
test("正常系は完了し、補償は呼ばれない", async () => {
  const w = createWorld();
  const log: string[] = [];
  assert.equal((await runSaga(w.steps, "o-1", log)).status, "completed");
  assert.equal(verbs(log, "compensate:").length, 0);
  assert.equal(w.state.stock, 4);
});
npx tsx --test saga.test.ts

手元(Node.js 20.18、tsx 経由の node:test)では14本すべてが通りました。内訳は注入点6つ × 2本、補償の再試行1本、正常系1本です。通っただけでは、テストが失敗を検出できるかは分かりません。次の節でそれを確かめます。

テストが本当に失敗を拾うか — 壊して確かめる

サーガのテストは、実装を意図的に壊したときに落ちて初めて信用できます。手元では実装に3種類の変更を入れ、同じテストを流しました。

壊し方結果(14本中)拾ったテスト
補償を逆順でなく実行順に流す結果(14本中)4本失敗拾ったテスト順序を見る失敗注入テスト
失敗したステップを補償対象から外す結果(14本中)9本失敗拾ったテストafter を含む失敗注入・冪等性の両方
返金を「引く」処理に変える(非冪等)結果(14本中)3本失敗拾ったテスト冪等性テストだけ

3つ目が重要です。返金を「3000円を引く」に変えると、1回目の補償では残高が0になるので、失敗注入テストは通ります。落ちたのは、補償を再実行する冪等性テストだけでした。補償を1回しか流さないテストでは、この種のバグは出ません。

落とし穴

補償を1回だけ流すテストは非冪等を通してしまう

補償は再試行されるので、2回目以降の結果が本番の挙動になります。失敗注入のあと、補償を全部もう一度流して状態を比べるテストを必ず足します。補償そのものが失敗して再試行される経路は、別テストで注入します。

Claude Codeに書かせる指示とCLAUDE.md

Claude Codeのベストプラクティスは、完了条件を渡す方法として「テストを実行して通るまで続けさせる」「証拠としてコマンドと出力を求める」を挙げています。サーガでは、完了条件を「全注入点で通ること」と明記するのが要点です。次のようなCLAUDE.mdの断片を置くと、毎回の指示が短くなります。

## サーガのテスト
- ステップは `{ name, run, compensate }`。`compensate` は冪等にする
  (未実行でも、2回目でも何も壊さない)
- 失敗注入は全ステップ × before/after の総当たり。
  失敗したステップ自身も補償対象に含める
- 補償の期待順序は仕様として決め、順序をテストで固定する
- 実装を意図的に壊してテストが落ちることを確認する(補償を
  順方向にする / 失敗ステップを外す / 返金を非冪等にする)
- 完了報告には `npx tsx --test` の出力を付ける

依頼文は、実装より先にテストの形を固定します。

注文サーガ(reserve → charge → label)に補償を実装して。
先に saga.test.ts を書き、全ステップ × before/after に失敗を注入して
状態が初期値に戻ること、補償が実行順の逆であること、補償を再実行
しても状態が変わらないことを検証して。全部通るまで直し、最後に
テストの出力を貼って。

既存のコードがある場合は、Claude Codeが実装を読んで「注入点の漏れ」を指摘する役割も有効です。再現テストを先に書かせる流れと同じで、落ちるテストを先に見せてから直させます。

サブエージェントとStopフックで抜けを減らす

注入点の網羅は、人間もAIも書き忘れます。サブエージェントの定義ファイルは .claude/agents/ に置け、tools で使えるツールを絞れます(省略すると、サブエージェントが使えるツールをすべて継承します)。注入点のレビュー専用に、読み取りだけのサブエージェントを用意する例です。

---
name: saga-fault-reviewer
description: サーガの実装とテストを読み、失敗注入の抜けを指摘する。サーガのテストを書いた後に使う
tools: Read, Grep, Glob
---
 
サーガの実装とテストを読み、次を表にして報告する。
1. ステップごとの before/after 注入が、テストにあるか
2. 補償が「未実行でも何もしない」「2回目も同じ」になっているか
3. 補償自体が失敗する経路のテストがあるか
実装やテストは編集しない。

実装を意図的に壊す確認は、作業ツリーを汚す危険があります。サブエージェントのフロントマターには isolation: worktree があり、一時的なgit worktreeで動かせます。変更がなければ自動で片付けられます。壊す確認だけをこの設定のサブエージェントに任せると、本線のブランチに壊れたコードが残りません。

完了前に必ずテストを通させたいなら、Stopフックです。ベストプラクティスの例では、Stop フックがexit code 2を返すと、Claudeは終了できずに作業へ戻されます。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "npx tsx --test saga.test.ts 1>&2 || exit 2" }
        ]
      }
    ]
  }
}

標準エラーに流した出力が、終われない理由としてClaudeに返ります。連続8回ブロックしてもツール呼び出しがなければ、次のブロックは上書きされてターンが終わります。永遠には止めません。テストが壊れたまま終わらせたくない場面に絞って使います。

補償の失敗も総当たりに加える

サンプルのテストで補償の失敗を注入しているのは、charge の1か所だけです。本来は補償も「ステップ数」だけ注入点があります。3ステップなら、reserve・charge・label の補償をそれぞれ一度失敗させる3通りです。createWorld の第2引数にステップ名を渡しているので、names をループするだけで広げられます。

もう一つ、サンプルが検証していない経路があります。補償が再試行の上限まで失敗し続けたときの stuck です。ここは「元に戻る」が期待値になりません。結果が stuck で、どのステップの補償が残ったかが返ることを期待値にします。Claude Codeに依頼するときは、「補償が永続的に失敗するケースは、状態が戻ることではなく、残った補償の名前が結果に出ることを検証して」と書き分けます。

補償がうまくいかない代表的な形

テストを書いていて、実装側で引っかかりやすい点を並べます。

  • 未実行の補償でエラーになる: reserve/before で落ちたのに release が「予約が見つからない」と例外を投げ、補償全体が止まる。「無ければ何もしない」にする
  • 補償の再試行が二重に効く: 返金額を加算するなど、2回目で結果が変わる。冪等キー(注文ID)で一度きりにする
  • 補償に失敗したまま握りつぶす: サンプルの stuck のように、補償できなかったステップを結果で返し、アラートや手動対応につなぐ。Azureのガイドも、手動介入が必要な場合に失敗の詳細つきで通知を上げるよう書いています
  • 補償を諦めて前に進める選択を考えていない: 一部の失敗は、代替案で処理を進めたほうがよい場合があります。Azureのガイドは、旅行予約で別ホテルを提案する例を挙げています。この場合の期待値は「元に戻る」ではなく「代替で完了する」です

この中の冪等性の部分は、キューや外部通知の重複配信を扱う記事にも共通します。SidekiqのジョブをClaude Codeで書く手順や、Stripe webhookの冪等受信でも、同じ「二度流しても同じ」をテストの軸にしています。

このサンプルが扱っていないこと

サンプルは単一プロセスのインメモリです。実際のサーガでは、次の点が別途必要です。

  • サーガの進行状況の永続化。プロセスが落ちても、失敗地点から補償を再開できるようにする
  • サービス間の分離性の欠如への対策。パターン解説は、並行するサーガ同士でデータの異常が起きうるとし、分離性を補う設計(カウンターメジャー)が要ると説明しています
  • DB更新とメッセージ送信の原子性。アウトボックスパターンのクラッシュ試験の領域です

進行状況の永続化や再開をフレームワークに任せたい場合は、冒頭で触れたTemporalのように、実行履歴を持つ基盤が選択肢になります。ここで作った「失敗位置の総当たり」の表と冪等性テストは、基盤を替えても期待値としてそのまま使えます。

まとめ

サーガの補償は、正常系のテストでは検証できません。ステップ数 × before/afterの総当たりで失敗を注入し、元に戻ること・順序・再実行しても同じことを固定すると、補償の実装は手早く直せます。壊して確かめる確認までをClaude Codeの完了条件に入れておくと、「通るテスト」ではなく「拾えるテスト」が手に入ります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/sub-agents
  2. code.claude.com/docs/en/best-practices
  3. microservices.io/patterns/data/saga.html
  4. learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction
この記事を共有:XはてブLinkedIn