きっかけは、深夜に二重課金の問い合わせが来たことでした
決済とプロビジョニングをまたぐ処理を Temporal に載せ替えて運用を始めた数週間後、「同じ請求が二回来ている」という問い合わせを受け取りました。ログを追うと、決済アクティビティがタイムアウト判定されてリトライされ、実際には一回目も成功していた、という典型的なパターンでした。
Temporal は「ワークフローのコードそのものを永続的な実行状態として扱う」ことで、ワーカーが落ちても正確な地点から再開してくれます。ただ、この再開の強力さは諸刃でもあります。アクティビティは設計次第で平気で複数回実行されますし、そのことを忘れた瞬間に副作用が二重に走ります。
ここでは、Temporal を本番運用に据えてから実際につまずいた箇所を中心に、冪等性・リトライの線引き・Saga の補償・可観測性という四つの観点で実装の勘所を整理します。入門記事ではなく、すでに動かしている人が「ここで事故った」を減らすためのメモとして読んでいただければ幸いです。開発自体は Antigravity のエージェントに任せる前提で、人間が握っておくべき判断はどこかも添えていきます。
「最低1回」を前提に置くと設計が変わります
Temporal のアクティビティに対してよく言われるのが at-least-once、つまり「最低1回は実行される」という保証です。「ちょうど1回」ではありません。タイムアウト、ワーカーのクラッシュ、ネットワークの一過性の失敗——どれが起きても、Temporal はアクティビティをもう一度呼びます。
ここで大事なのは、リトライが「失敗したから」だけでなく「成功したのに結果が返ってこなかったから」起きるという点です。決済 API が処理を完了したのにレスポンスが返る前に接続が切れれば、Temporal から見れば失敗で、もう一度叩きにいきます。だからこそ、副作用を持つアクティビティはすべて冪等であることが前提になります。
冪等化の手段は副作用の種類で変わります。自分の DB への書き込みなら一意制約と ON CONFLICT で吸収できますし、外部 API なら相手の冪等キー機能に乗るのが確実です。
// src/temporal/activities/billing.ts
import { ApplicationFailure } from '@temporalio/activity' ;
import { db, charges } from '../../db' ;
import { stripe } from '../../lib/stripe' ;
interface ChargeInput {
orderId : string ; // ワークフロー側で確定済みの安定したID
customerId : string ;
amount : number ;
currency : string ;
}
/**
* 課金アクティビティ
* 冪等性は2層で担保する:
* 1) Stripe の idempotencyKey で「同一リクエストは1度だけ処理」を相手側に保証させる
* 2) 自分の charges テーブルにも結果を一意キーで記録し、再実行時は記録を正とする
*/
export async function chargeCustomer ( input : ChargeInput ) : Promise < string > {
const { orderId , customerId , amount , currency } = input;
// 先に自分側の記録を確認する。すでに成功記録があれば API を叩かず返す
const existing = await db.query.charges. findFirst ({
where : ( c , { eq }) => eq (c.orderId, orderId),
});
if (existing?.status === 'succeeded' ) {
return existing.stripeChargeId;
}
// orderId をそのまま冪等キーにする。リトライしても Stripe 側で二重課金されない
const intent = await stripe.paymentIntents. create (
{ amount, currency, customer: customerId, confirm: true },
{ idempotencyKey: `charge-${ orderId }` },
);
if (intent.status !== 'succeeded' ) {
// ここはビジネス的な失敗。リトライしても結果は変わらないので非リトライにする
throw ApplicationFailure. create ({
message: `決済が完了しませんでした: ${ intent . status }` ,
type: 'PaymentNotCompleted' ,
nonRetryable: true ,
});
}
await db
. insert (charges)
. values ({ orderId, stripeChargeId: intent.id, status: 'succeeded' , amount })
. onConflictDoNothing ();
return intent.id;
}
この書き方で効いているのは、冪等キーに「ワークフロー側で確定した安定した ID」を使っている点です。アクティビティ内で crypto.randomUUID() を呼んでキーにすると、リトライのたびに値が変わって冪等性が崩れます。キーになる ID は必ずワークフロー本体で確定させ、引数として渡してください。Temporal のワークフローは決定的に再実行されるので、ワークフロー内で生成した ID はリトライをまたいでも同じ値になります。
冒頭の二重課金は、まさにこの「自分側の記録を先に見る」一手が抜けていたために起きました。Stripe の冪等キーだけに頼っていて、キーの生成がアクティビティ内にあったのが直接の原因でした。
リトライしてよいエラーを、型で線引きします
Temporal はデフォルトで失敗したアクティビティを指数バックオフでリトライします。便利ですが、何でもリトライしてよいわけではありません。「入力が不正」「残高不足」のような、何度やっても結果が変わらない失敗をリトライし続けるのは、時間と外部 API の予算を溶かすだけです。
私自身は、個人開発でバックエンドを運用する中で、失敗を次の三つに分けて考えるようになりました。
一過性で再試行すれば直る失敗(ネットワーク、5xx、レート制限)
何度やっても無駄なビジネス上の失敗(バリデーション、決済拒否)
人間が気づくべき設定ミス(認証エラー、存在しないリソース)
このうち 1 だけをリトライ対象にして、2 と 3 は即座に止めることを推奨します。よくある落とし穴は、3 を一過性の失敗と同じ扱いにしてしまい、認証切れのまま延々とリトライさせてしまうことです。
Temporal では二つの方法で線引きします。ワークフロー側でアクティビティごとに retry.nonRetryableErrorTypes を指定する方法と、アクティビティ側で ApplicationFailure に nonRetryable: true を立てる方法です。前者は「このアクティビティではこの型は止める」という宣言、後者は「この失敗は性質上リトライ不要」という宣言で、役割が違います。
// src/temporal/workflows/checkout.ts
import { proxyActivities } from '@temporalio/workflow' ;
import type * as acts from '../activities/billing' ;
const { chargeCustomer } = proxyActivities < typeof acts>({
startToCloseTimeout: '30 seconds' ,
retry: {
initialInterval: '1s' ,
backoffCoefficient: 2 ,
maximumInterval: '30s' ,
maximumAttempts: 5 ,
// バリデーション系・ビジネス上の確定失敗はリトライしない
nonRetryableErrorTypes: [ 'PaymentNotCompleted' , 'InvalidInput' ],
},
});
ここで一点だけ注意があります。maximumAttempts を無制限(0)のままにすると、復旧不能な失敗が永遠にリトライされ続け、ワークフローが「実行中」のまま滞留します。可観測性の観点でも厄介なので、私は外部依存のあるアクティビティには必ず上限を置き、上限到達はアラート対象にしています。リトライで吸収できない失敗が起きていること自体が、運用上は見たい情報だからです。
startToCloseTimeout も忘れずに設定してください。これがないと、相手が無言で固まったアクティビティを Temporal が失敗と判定できず、リトライが始まりません。タイムアウト値は「正常時の処理時間の余裕を見た上限」にします。長すぎると障害検知が遅れ、短すぎると正常な処理を誤ってリトライします。
Saga の補償は、中途半端に効くと一番こわい
複数サービスにまたがる処理で、途中まで成功して後半が失敗した時に、すでに成功した分を取り消すのが Saga パターンの補償(compensation)です。Temporal では補償をワークフローのコードとして素直に書けます。ただ、ここで一番事故りやすいのは「補償そのものが失敗してリトライされる」場面です。
たとえば在庫を確保し、課金し、配送を手配する三段階で、配送手配が失敗したとします。課金の取り消しと在庫の解放という二つの補償を走らせますが、課金取り消しの API がタイムアウトすればこれもリトライされます。補償が冪等でなければ、在庫を二重に戻したり、返金を二回試みたりします。つまり、本処理だけでなく補償アクティビティも冪等でなければいけません。
// src/temporal/workflows/fulfillment.ts
import { proxyActivities, log } from '@temporalio/workflow' ;
import type * as acts from '../activities' ;
const a = proxyActivities < typeof acts>({
startToCloseTimeout: '30 seconds' ,
retry: { maximumAttempts: 5 },
});
export async function fulfillOrder ( order : OrderInput ) : Promise < void > {
// 実行した処理の取り消し手順をスタックに積んでいく
const compensations : Array <() => Promise < void >> = [];
try {
const reservation = await a. reserveInventory (order);
compensations. push (() => a. releaseInventory (reservation.id));
const chargeId = await a. chargeCustomer (order);
// 補償は「実行済みの結果ID」を引数に取り、冪等に取り消す
compensations. push (() => a. refundCharge ({ chargeId, orderId: order.id }));
await a. scheduleShipment (order);
} catch (err) {
log. warn ( '本処理が失敗。補償を逆順で実行します' , { orderId: order.id });
// 後に積んだものから戻すのが鉄則(依存関係の逆順)
for ( const compensate of compensations. reverse ()) {
try {
await compensate ();
} catch (compErr) {
// 補償の失敗は握りつぶさず、専用シグナルやログで人間に上げる
log. error ( '補償に失敗。手動対応が必要です' , { orderId: order.id, compErr });
}
}
throw err; // ワークフローとしては失敗で確定させる
}
}
設計上のポイントは三つあります。一つ目は、補償をスタックに積んで「実行した処理の分だけ、逆順で」戻すこと。最初から全部の取り消しを呼ぶと、まだ実行していない処理まで取り消そうとして別の事故を生みます。二つ目は、補償アクティビティが受け取るのは「取り消し対象の結果 ID」であって、再計算ではないこと。refundCharge は冪等な決済の取り消しで、二回呼ばれても返金は一回です。三つ目は、補償の失敗を握りつぶさないこと。補償が失敗した注文は、整合性が崩れた可能性のある最重要の調査対象なので、ログに流すだけでなく運用チャンネルへ通知します。
個人開発で運用していると「補償が失敗するなんて稀だろう」と省略したくなりますが、稀に起きた時に静かに不整合が残るのが一番こわいので、ここだけは手を抜かない方針にしています。
可観測性は「ワークフローの意味」が見える粒度で
Temporal の Web UI は実行履歴を時系列で見せてくれて、それだけでも相当デバッグが楽になります。ただ、本番では既存の分散トレーシングと地続きで追いたくなるので、OpenTelemetry と繋ぎます。@temporalio/interceptors-opentelemetry を使うと、ワークフローとアクティビティのスパンが自動で張られます。
// src/temporal/worker.ts
import { Worker, defaultSinks } from '@temporalio/worker' ;
import {
makeWorkflowExporter,
OpenTelemetryActivityInboundInterceptor,
} from '@temporalio/interceptors-opentelemetry/lib/worker' ;
import { resource, traceExporter } from './otel' ;
async function run () {
const worker = await Worker. create ({
workflowsPath: require. resolve ( './workflows' ),
activities: require ( './activities' ),
taskQueue: 'order-processing' ,
sinks: { ... defaultSinks (), ... makeWorkflowExporter (traceExporter, resource) },
interceptors: {
activityInbound: [( ctx ) => new OpenTelemetryActivityInboundInterceptor (ctx)],
},
});
await worker. run ();
}
run (). catch (( e ) => { console. error (e); process. exit ( 1 ); });
トレースが繋がるだけでも価値はありますが、運用していて本当に効いたのは Search Attribute でした。orderId や customerId をワークフローの検索属性に登録しておくと、問い合わせが来た時に「この顧客のこの注文のワークフロー」を ID で一発で引けます。トレース ID を知らない問い合わせ窓口でも、業務の言葉でワークフローを特定できるのが実務では大きいです。
// ワークフロー起動時に検索属性を付ける
await client.workflow. start (fulfillOrder, {
taskQueue: 'order-processing' ,
workflowId: `order-${ order . id }` , // 業務IDをそのままワークフローIDに
args: [order],
searchAttributes: { CustomerId: [order.customerId] },
});
メトリクスとしては、滞留しているワークフロー数、リトライ上限に到達したアクティビティの件数、補償が走った件数の三つを最初にダッシュボード化しました。この三つは「自動回復で吸収しきれなかった異常」を表す指標で、Temporal が静かに頑張りすぎて問題が見えなくなる事態を防いでくれます。
Antigravity に任せる部分と、人間が握る部分
ここまでの実装を Antigravity のエージェントに任せる時、私は「冪等性とリトライ分類とSaga 補償の方針」だけは AGENTS.md に明文化して握っておくようにしています。コードの大半はエージェントが書けますが、「どのエラーを非リトライにするか」「補償の順序と冪等化」は業務の意味に踏み込む判断で、ここを曖昧にしたまま生成すると、動くけれど事故るコードになりがちだからです。
逆に、アクティビティの雛形、テストの土台、OpenTelemetry の配線といった定型は、方針さえ渡せばエージェントが手早く整えてくれます。実際、ワーカーの起動部分やリトライポリシーの素案はほとんど生成に任せ、私は冪等キーの取り方と補償の正しさをレビューする時間に集中できました。判断の所在を分けておくことが、AI 駆動で本番品質を保つ上での現実的な線引きだと感じています。
次に手を動かすなら、いま動かしているワークフローのアクティビティを一つだけ選び、「リトライで二回実行されたら何が二重になるか」を紙に書き出してみてください。そこが冪等化の最初の一歩で、たいていは一番事故りやすい一箇所がそこで見つかります。