AIニュース最前線
最新ニュースAI日報Hacker日報週報動画AIツールトレンド企業

AIニュース最前線

世界中のAI最新情報を日本語で毎時更新

最新ニュース日報トレンド企業このサイトについてRSS
© 2026 ainew.jp
お問い合わせ特定商取引法に基づく表記
ニュース一覧元記事を開く
Cloudflare Blog·2026年6月25日 22:00·約20分で読める

Cloudflare Workflows のサガロールバック機能の構築方法について

#ワークフロー自動化#分散トランザクション#Cloudflare Workflows#サガパターン
TL;DR

Cloudflare は Workflows にサガパターンに基づくロールバック機能を組み込み、分散トランザクションにおける不整合状態の自動修復を容易にした。

AI深層分析2026年6月25日 23:04
3
注目/ 5段階
深度40%
4
関連度30%
2
実用性20%
4
革新性10%
3

キーポイント

1

サガパターンのネイティブ実装

各ステップに補償ロジック(rollback)を定義可能にし、外部システムとの連携失敗時に手動で複雑な巻き戻しコードを書く必要がなくなった。

2

分散トランザクションの信頼性向上

銀行送金などの例に示されるように、一部のステップ成功後に他社システムで失敗した場合でも、整合性を保つための自動的な逆処理が可能になる。

3

コードの簡素化と保守性の向上

従来の try-catch ブロック内で手動で状態を追跡・管理する複雑なロジックから解放され、ステップ定義内にロールバックを記述することで可読性が向上した。

4

ロールバックの定義方法

step.do() の引数としてオプションオブジェクトを渡すことで、各ステップに独自のロールバック関数を簡単に追加できます。

5

実行順序と失敗時の挙動

エラーが発生すると、登録されたロールバックハンドラはステップ開始の逆順で自動的に実行されますが、ユーザーコード内でエラーを捕捉して処理を続行させた場合は実行されません。

6

イデムポテンシーの重要性

ロールバック関数も通常のワークフローステップと同様にイデムポテントである必要があり、再試行しても二重処理を防ぐためにプロバイダー固有のキーを使用する必要があります。

7

ロールバックのトリガー条件

ステップのエラー発生直後に即座にロールバックされるわけではなく、ワークフロー自体が最終的に失敗する時点でのみロールバック処理が開始されます。

影響分析・編集コメントを表示

影響分析

この機能追加は、マルチステップで構成される複雑な業務プロセスや金融トランザクションなど、高い整合性を要求するアプリケーション開発において、Cloudflare Workflows の実用性を劇的に高めます。開発者は分散システム特有の複雑なエラーハンドリングから解放され、より本質的なビジネスロジックに集中できるようになります。

編集コメント

AI 技術そのものの進化というよりは、インフラプラットフォームが複雑な業務ロジックの信頼性を担保する機能として成熟したことを示す重要なアップデートです。

Cloudflare Workflows を利用すれば、長期実行プロセスにわたる組み込みのリトライ機能と状態永続化を備えた、耐久性のある多ステップアプリケーションを構築できます。Workflow が実行されると、各ステップは外部システムを呼び出したり、失敗をリトライしたり、再起動間での状態を保持したりできます。しかし、1 つのステップで失敗が発生すると、完了したステップからの以前の作業が不整合または不完全な状態で残ってしまう可能性があります。

本日、Workflows に対してサガロールバック機能をリリースしました。これにより、失敗時に備えて各ステップ内でロールバックロジックを宣言できるようになります。

例えば、2 つの異なる銀行間での資金送金を行うワークフローを考えてみましょう:

銀行 A の口座から引き落とし

銀行 B の口座へ入金

両口座所有者に確認メールを送信

もしステップ 2、つまり銀行 B への入金が失敗した場合どうなるでしょうか?銀行 A での引き落としが成功すると、取引は確定し、資金はそのシステムから移動してしまいます。取引のオーケストレーターとして、銀行 A のシステムで単に操作を「元に戻す」ことはできません。代わりに、最初の操作を意味的に逆転させる新しい操作を通じて、資金を銀行 A の口座へ戻す必要があります。

imageimage

この操作とその補償ロジックの組み合わせは、サガパターン(saga pattern)と呼ばれます。

今日まで、開発者は成功したものと失敗したもの、そして失敗時にどのアクションを取るべきかを追跡するために、ステップの直接定義の外側で独自の補償ロジックを実装する必要がありました。今では、各ステップに対して補償ロジックを定義できるようになりました。do() をステップ内の引数として使用することで、ロールバック時のワークフローの耐久性も維持できます。

// 何完了したか追跡して、何を元に戻すべきか知る

let debitA;

let creditB;

try {

debitA = await step.do("debit-bank-a", () => bankA.debit(from, amount));

creditB = await step.do("credit-bank-b", () => bankB.credit(to, amount));

await step.do("notify", () => notifyBoth(from, to, amount));

} catch (error) {

// 逆順で元に戻す。各元に戻す操作はそれ自体が耐久性を持つステップであり、

// 冪等性を持たねばならず、一つでも失敗しても続行する必要がある。

if (creditB) {

try {

await step.do("reverse-credit-b", () => bankA.debit(to, amount, creditB.id));

} catch (e) {

await alertOnCall("reverse-credit-b failed", e);

}

}

if (debitA) {

try {

await step.do("refund-debit-a", () => bankA.credit(from, amount, debitA.id));

} catch (e) {

await alertOnCall("refund-debit-a failed", e);

}

}

throw error;

}

ロールバックなし

// 各ステップには独自の元に戻す機能が備わっています。ステップを追加する際は、そのロールバックもここに追加してください。キャッチブロックの肥大化も、手動での順序付けも、再生ロジックも不要です。

await step.do("debit-bank-a", () => bankA.debit(from, amount), {

rollback: async ({ output }) => bankA.credit(from, amount, output.id),

});

await step.do("credit-bank-b", () => bankB.credit(to, amount), {

rollback: async ({ output }) => bankB.debit(to, amount, output.id),

});

await step.do("notify", () => notifyBoth(from, to, amount));

With rollbacks

Try it out

ロールバックを使用するには、step.do() の最後の引数として、ロールバック関数を含むオプションオブジェクトを渡すだけです。

const debit = await step.do(

"debit-account-a",

async () => {

return await bankA.debit({

accountId: fromAccountId,

amount,

idempotencyKey: ${transferId}:debit-account-a,

});

},

{

rollback: async () => {

await bankA.credit({

accountId: fromAccountId,

amount,

idempotencyKey: ${transferId}:rollback-debit-account-a,

});

},

}

);

// 二重実行防止キー(idempotency keys)により、転送を重複させることなく、前方操作とロールバック操作の両方を安全に再試行できます

const credit = await step.do(

"credit-account-b",

async () => {

return await bankB.credit({

accountId: toAccountId,

amount,

idempotencyKey: ${transferId}:credit-account-b,

});

},

{

rollback: async ({ output }) => {

if (output === undefined) {

return;

}

await bankB.debit({

accountId: toAccountId,

amount,

idempotencyKey: ${transferId}:rollback-credit-account-b,

});

},

}

);

// If we fail here, we may want to revert all previous payments. Users should not have to wrap their code in complex try-catch logic just to revert two small payments (see below)

await step.do("send-confirmation", async () => {

await sendTransferConfirmation({ ... });

});

Rollback functions should be idempotent, just like regular Workflow steps. If you refund a charge, use the payment provider's idempotency key. If you release inventory, make the release safe to call more than once.

If any step fails, the rollback handlers will execute in reverse step-start order. It sounds simple: run the undo steps when something fails. In practice, there are a few details that make the API and execution model important.

  1. The failed step may still need rollback. A failed step.do() can still be rollback-eligible if it registered a rollback handler.

ロールバックは、ユーザーコードがエラーを捕捉してワークフローが継続する場合は開始されませんが、ステップエラーが捕捉された後にワークフローが別の理由で失敗した場合でも、以前に登録されたハンドラーに対してロールバックを実行できます。これらは、ステップ開始の逆順で実行されます。

なぜでしょうか?ステップは失敗する前に外部システムと部分的に相互作用している可能性があります。例えば、支払いプロバイダーが課金をキャプチャしたとしても、chargeId をワークフローに返す前にステップが失敗することがあります。そのため、ロールバックハンドラーには出力が渡されますが、出力が undefined の場合も処理できるようにする必要があります。

  1. ロールバックは、ワークフローが失敗した場合のみ開始されます。ロールバックハンドラーを追加しても、すべてのステップエラーがロールバックを引き起こすわけではありません。ユーザーコードがエラーを捕捉して継続する場合、ワークフローは継続します。ロールバックは、ワークフロー自体が最終的に失敗しようとするときに開始されます。

ロールバックが開始されると、ワークフローは対象となる step.do() 呼び出しを検索し、それらのロールバックハンドラーを実行した後、最終的なワークフローの失敗を記録します。

  1. 順序は予測可能でなければなりません。順次実行されるワークフローの場合、ロールバックの順序は明白に感じられます:

在庫を予約する。

カードを課金する。

出荷を作成する。

もし出荷作成が失敗した場合、カードへの返金を行い、在庫を解放します。

並列ステップではこの状況はより複雑になります。完了順序が始動順序と異なる可能性があるため、ワークフローは逆順の完了順序ではなく、逆順の開始順序を使用します。

実用的なルールは以下の通りです:

ロールバックハンドラーを持つ開始済みまたは完了済みのすべてのステップが対象となります。

ロールバックハンドラーを登録している失敗した step.do() も対象となります。

ハンドラーは完了順ではなく、逆のステップ開始順序で実行されます。

API の設計方法について

期待する動作を把握した上で、この新しいパターンを Workflows API に追加する必要がありました。ロールバック機能については、最終的な「ロールバックオプション」にたどり着くまでにいくつかの試行錯誤が行われました。

なぜフラuent またはビルダー API ではないのか?

最初のアプローチは fluent フォームでした:step.do(...).rollback(...) これは読みやすく、前方アクションと補償処理が隣り合わせに配置され、呼び出し箇所も通常の JavaScript のチェーンのように見えます。

問題は、step.do() がすでに重要な意味を持っていることです。これは永続的なステップを開始し、そのステップの出力に対する Promise を返します。Workers においては、Promise に似た値は特に重要であり、これは Cap'n Proto などのシステムから継承されたパターンである「プロミス・パイプライン」をサポートする Workers RPC のためです。

プロミス・パイプラインにより、コードは値が呼び出し元に完全に返される前に、その将来の値に対してメソッドを呼び出すことができます。例えば:

const session = api.authenticate(apiKey);

const name = await session.whoami();

ここで、session はまだ実際のセッションオブジェクトではありません。これはまもなく存在するセッションへのハンドルのようなものです。session.whoami() を呼び出すと、Workers はその呼び出しをリモート側に早期に送信し、「認証によってセッションが作成されたら、その上で whoami() を呼び出してください」と伝えることができます。

これにより往復通信が不要になります。呼び出し元は、whoami() を要求する前に authenticate() が完全に完了するまで待つ必要はありません。

私たちは流暢な API(fluent API)も検討しました:

step.do("charge-card", chargeCard).rollback(refundCharge);

読者にとっては、「charge-card の結果に対して .rollback() を呼び出す」ように見えるかもしれません。しかし、rollback はステップの出力の一部ではありません。これはステップ開始前に登録される step.do() オプションの一部であり、後続のステップで失敗した場合にワークフローがそのステップを補償する方法を知るためです。

流暢な API では、ステップの実行タイミングを推論することが難しくなります。現在のところ、step.do() は呼び出された瞬間にステップを開始するため、開発者はステップを開始して他の処理を行い、後から最初のステップの完了を待機できます:

const first = step.do("first", () => serviceA.call());

await step.do("second", () => serviceB.call());

await first;

現在の実行モデルでは、first は second よりも前に即座に開始されます。流暢な API を採用するとこれが複雑化します。ワークフローは、完全なステップ定義がわかるまで .rollback() が付与されるかどうかを待機する必要があり、その結果、エンジンへのステップ送信が遅れる可能性があります。

前述の例では、first は step.do("first", ...) の呼び出し時ではなく、await first 時点で開始され、second が既に完了した後に実行されます。

これにより、並行して実行されるワークフローの推論がより困難になります。ステップのタイミングは、step.do() が呼び出された場所だけでなく、返された Promise がいつ消費されるかにも依存することになるからです。

私たちはビルダー型 API も検討しました:

const charge = await step

.saga("charge")

.do(() => chargeCard())

.rollback(() => refundCharge())

.run();

ビルダー API は Promise の曖昧さを回避します。また、将来のステップレベルでのオプションを追加するための明確な場所を提供し、フォワードアクションとロールバックアクションが同じサージステップに属していることを明確に示します。

しかし、これには儀式(形式)が増えるという欠点があります。すべてのステップで最終的に .run() が必要となり、.run() を忘れるのは容易であり、ツールがないと見つけるのが困難です。また、単純なワンステップケースが設定チェーンのように見え始めてしまいます。さらに、既存の step. パターンから逸脱する新しい step.saga() ビルダーを導入することになります。最も重要なのは、step.do() が古い API のように感じられ、ワークフローの主要なプリミティブではなくなってしまう点です。ロールバックの目的は、step.do() を置き換えるのではなく拡張することでした。

ステップメタデータとしてのロールバック

step.do(..., { rollback })

最終的に、ロールバックをステップのメタデータとする明示的な形式を採用しました。

このように、各ロールバックはフォワードステップ自体の中で定義されます。各ハンドラーは、ロールバック開始の原因となったエラー、ステップコンテキスト、および出力を受け取ります。出力とは、フォワードステップによって返された永続化値(未定義の場合あり)か、または値を永続化する前にステップが失敗した場合は未定義です。

ロールバックはライフサイクルイベントを発行するため、補償処理の開始、どのロールバックハンドラーが失敗したか、そしてロールバックが正常に完了したかどうかを確認できます。

重要なのは、元のワークフローの失敗は分離されたまま維持される点です。ロールバックは、失敗後にワークフローが行う処理であり、ワークフローが失敗した理由そのものではありません。

ステップ設定を通じて WorkflowStepConfig でカスタムのリトライおよびタイムアウト動作を定義できるのと同様に、rollbackConfig 内でロールバック固有の値を追加できます。

{

rollback: async ({ output }) => {

await bankA.credit({ accountId: fromAccountId, amount, transferId: ${transferId}-reversal });

},

rollbackConfig: {

retries: { limit: 10, delay: '30 seconds', backoff: 'exponential' },

timeout: '2 minutes',

},

}

これは、私たちが目指したライフサイクルイベントのメンタルモデルに合致しています。step.do() はすでに、ワークフローが記録し、リトライし、後でログに表示する永続的な作業単位を記述します。ロールバックは、同じ作業単位に対する別のライフサイクル動作です。これは別々のラッパーやビルダーに存在するのではなく、ステップ定義と一緒に移動すべきものです。

ステップは、step.do() が通常開始されるタイミングで依然として開始されます。

返されたプロミスは依然としてステップの出力を表します。

並行ワークフローコードは、同じ実行モデルを維持します。

ロールバックのリトライおよびタイムアウトオプションは、ロールバックハンドラーの隣に配置されます。

既存の step.do() 呼び出しは、今日と同じように正確に動作し続けます。

この形状は、流暢な API よりもやや明示的ですが、その明示性は有用です。操作とその補償は依然として一つの場所にあり、API は新しいステップビルダーや新しい種類のプロミスを導入しません。すでに step.do() を理解している開発者にとって、学ぶべきは追加のオプションオブジェクトのみです。

これは魔法のようなものではありませんが、採用が容易で、理解も明確です。

内部での仕組み

ロールバックは小さな API の追加のように思えますが、ワークフローが各ステップについて記録する内容を変化させます。

通常の step.do() はすでに永続的な記録を持っています。ワークフローは、そのステップが開始されたか、完了したか、何を返したか、そして後でワークフローが再開された場合に繰り返し実行するのではなくスキップすべきかを記録します。

ロールバックはこの記録にさらに一つの要素を加えます:そのステップが補償ロジックを登録したかどうかです。

これは、ワークフローが失敗した場合に、ワークフローが二つの情報を統合しなければならないことを意味します。

第一は永続的なステップ履歴です。ワークフローエンジンは、何を実行したか、何が完了したか、どのような出力が保存されたか、そしてロールバックが登録されたかどうかを知るためにデータを保存します。

2 つ目はロールバックハンドラーそのもので、これはそのステップを補償するために記述された関数です。Workflows はこの関数のテキストデータを保存するのではなく、Workflow が実行されている間、ハンドラーへの呼び出し可能な参照を保持します。

Workers RPC では、このような呼び出し可能な参照はスタブと呼ばれます。スタブにより、システムの一方の部分がどこかで実行中のコードを呼び出すことが可能になります。また、スタブにはライフタイムがあり、呼び出しや実行コンテキストが終了した際に破棄されます。この時点以降もスタブを保持する必要がある場合、Workers RPC は dup() メソッドを提供しており、これにより同じターゲットへの別のハンドルを作成できます。

ロールバックにおいては、このモデルが有用です。永続的なステップ履歴は、何を補償すべきかを記録します。ロールバックスタブは Workflows に対して、補償コードを呼び出す手段を与えます。さらに、ロールバックハンドラーはそれらを登録した即座の step.do() 呼び出しよりも長く存続する必要がある場合があるため、Workflows はロールバックフェーズのためにハンドラー自体への独自の呼び出し可能な参照を保持します。

一般的なケースでは、Workflow が同じエンジンライフタイム内でロールバックに入る場合、Workflows はすでに必要なロールバックスタブを持っています。永続的なステップ履歴を使用して対象となるステップを見つけ、前方実行中に登録されたロールバックスタブを呼び出すことができます。

しかし、Workflows が再起動後に回復しなければならない場合は、この状況はより微妙になります。

エンジンがロールバックが必要な際に退去、クラッシュ、または再起動した場合でも、Workflows は永続的なステップ履歴を保持していますが、メモリ上のロールバック用スタブは失われている可能性があります。回復のために Workflows は再生(replay)を使用します。これは完了した前方ステップ本体を実行し直すことなく Workflow コードを再実行できる回復モードです。

再生が完了した step.do() に到達すると、Workflows はステップ本体を再度実行するのではなく、永続化された結果を読み取ります。ロールバック回復のためには、Workflows はロールバックが紐付けられておりロールバック対象となるステップに対してのみハンドラを再構築すれば十分です。これらの step.do() 呼び出しに遭遇する際、そのロールバックオプションは呼び出し可能なスタブを再度登録できます。

これにより、Workflows は元の外部副作用を重複させることなく必要なロールバックハンドラを回復できます。

image
image

これらの要素が整っていれば、ハンドラがメモリ上にまだ存在する場合でも、回復時に再構築が必要な場合でも、ロールバックは機能します。

ワークフローが失敗しようとする際、Workflows はアプリケーションに対して何が起きたかを再構築するよう求めません。すでにステップ履歴を保持しているからです。永続化された記録を確認し、重要な問いに答えることができます:

どのステップが始まったか?

どのステップが完了したか?

失敗したステップのうち、まだクリーンアップが必要なものはどれか?

どのステップがロールバックハンドラを登録したか?

各ロールバックハンドラに渡すべき出力は何か?

補償処理はどのような順序で実行すべきでしょうか?

その後、Workflows は各ロールバックスタブをロールバックコンテキスト(元のエラー、ステップのコンテキスト、および永続化された場合のステップ出力)と共に呼び出します。

この順序の詳細が重要です。通常の JavaScript、特に Promise.all() を使用する場合、完了順序は開始順序と必ずしも一致しません。ステップ A が最初に開始され、ステップ B が2番目に開始されたとしても、ステップ B が先に完了する可能性があります。ロールバックにおいては、Workflows は永続化された開始順序を安定した真実の源として用い、それを逆順で巻き戻します。

ロールバックハンドラもまた、Workflows の通常のステップ処理機構を通じて実行されます。つまり、補償処理には Workflows から期待される同じ運用特性が適用されます:リトライ、タイムアウト、ライフサイクルイベント、ログ、そして最終的に記録された結果です。もしロールバックハンドラが設定されたリトライ回数を超えても失敗し続ける場合、Workflows はロールバックの結果を「失敗」として記録し、残りのロールバックハンドラの実行を停止します。その結果、Workflow インスタンスは最終的に Errored 状態で終了します。

これがサガロールバックと catch ブロックの主な違いです。catch ブロックが把握できるのは、JavaScript 実行のその時点でのメモリ上に存在するものだけです。一方、Workflows のロールバックは永続化されたステップ履歴を用いて何が既に発生したかを判断し、一般的なケースではすでに用意されているスタブを呼び出し、回復時に必要な場合に不足しているスタブを安全に再構築します。

そのため、API はロールバックを step.do() 自体に配置しています。ロールバックは独立したグローバルエラーハンドラーではなく、Workflows がすでに理解している作業の永続単位に付随するメタデータです。

What's next

ロールバックの最初のイテレーションには以下が含まれます:

step.do() 用の明示的なステップ別ロールバックハンドラー

順次実行されるロールバック

補償のためのリトライおよびタイムアウト設定

次に、以下の探索を進めたいと考えています:

waitForEvent に対するロールバックサポート

並列ロールバック実行のサポート

Python Workflows に対するロールバックサポート

多ステップアプリケーションが途中で失敗した場合、最も難しい部分は、失敗したことに気づかないことではなく、すでに何が行われたか、そして次に何をすべきかを把握することです。

Saga ロールバックを使用すれば、その答えを各ステップのすぐそばに配置できます。Workflows で多ステップアプリケーションを構築している場合は、saga ロールバックを試していただき、次に必要な補償パターンについてご意見をお聞かせください。Workflows のドキュメントで始め、Cloudflare コミュニティフィードバックをご共有ください。

原文を表示

Cloudflare Workflows allows you to build durable, multi-step applications with built-in retries and state persistence across long-running processes. When a Workflow executes, each step can call external systems, retry failures, and persist state across restarts. But if one step fails, it may leave earlier work from completed steps in an inconsistent or partial state.

Today we’re shipping saga rollbacks for Workflows, allowing you to declare rollback logic within the step itself, in case of failure.

For example, consider a workflow for transferring funds between accounts at two different banks:

Debit from account at Bank A

Credit to account at Bank B

Send email confirmation to both account owners

What happens if Step 2, the credit to account at Bank B, fails? Once the debit succeeds at Bank A, the transaction is committed and the money has left its system. As the orchestrator of the transaction, you cannot simply “undo” the operation in Bank A's system. Instead, the money must be credited back to the account at Bank A through a new operation that semantically reverses the first one.

imageimage

This pairing of an operation and its compensation logic is called the saga pattern.

Before today, developers had to implement their own compensation logic to track what succeeded, what failed, and what actions should be taken upon failure, outside of the steps’ direct definitions. Now, you can define compensation logic for each step.do() as an argument within the steps themselves, maintaining your workflow’s durability for the rollback as well.

// track what completed so we know what to undo

let debitA;

let creditB;

try {

debitA = await step.do("debit-bank-a", () => bankA.debit(from, amount));

creditB = await step.do("credit-bank-b", () => bankB.credit(to, amount));

await step.do("notify", () => notifyBoth(from, to, amount));

} catch (error) {

// unwind in reverse. each undo is its own durable step,

// must be idempotent, and must keep going if one fails.

if (creditB) {

try {

await step.do("reverse-credit-b", () => bankB.debit(to, amount, creditB.id));

} catch (e) {

await alertOnCall("reverse-credit-b failed", e);

}

}

if (debitA) {

try {

await step.do("refund-debit-a", () => bankA.credit(from, amount, debitA.id));

} catch (e) {

await alertOnCall("refund-debit-a failed", e);

}

}

throw error;

}

Without rollbacks

// each step ships with its own undo. add a step,

// add its rollback right here. no growing catch

// block, no manual ordering, no replay logic.

await step.do("debit-bank-a", () => bankA.debit(from, amount), {

rollback: async ({ output }) => bankA.credit(from, amount, output.id),

});

await step.do("credit-bank-b", () => bankB.credit(to, amount), {

rollback: async ({ output }) => bankB.debit(to, amount, output.id),

});

await step.do("notify", () => notifyBoth(from, to, amount));

With rollbacks

Try it out

To use rollbacks, just pass an options object containing a rollback function as the last argument to step.do().

const debit = await step.do(

"debit-account-a",

async () => {

return await bankA.debit({

accountId: fromAccountId,

amount,

idempotencyKey: ${transferId}:debit-account-a,

});

},

{

rollback: async () => {

await bankA.credit({

accountId: fromAccountId,

amount,

idempotencyKey: ${transferId}:rollback-debit-account-a,

});

},

}

);

// The idempotency keys make both the forward operations and rollback operations safe to retry without duplicating the transfer

const credit = await step.do(

"credit-account-b",

async () => {

return await bankB.credit({

accountId: toAccountId,

amount,

idempotencyKey: ${transferId}:credit-account-b,

});

},

{

rollback: async ({ output }) => {

if (output === undefined) {

return;

}

await bankB.debit({

accountId: toAccountId,

amount,

idempotencyKey: ${transferId}:rollback-credit-account-b,

});

},

}

);

// If we fail here, we may want to revert all previous payments. Users should not have to wrap their code in complex try-catch logic just to revert two small payments (see below)

await step.do("send-confirmation", async () => {

await sendTransferConfirmation({ ... });

});

Rollback functions should be idempotent, just like regular Workflow steps. If you refund a charge, use the payment provider's idempotency key. If you release inventory, make the release safe to call more than once.

If any step fails, the rollback handlers will execute in reverse step-start order. It sounds simple: run the undo steps when something fails. In practice, there are a few details that make the API and execution model important.

  1. The failed step may still need rollback. A failed step.do() can still be rollback-eligible if it registered a rollback handler.

The rollback will not start if user code catches an error and the Workflow continues, but if a step error is caught and the Workflow later fails for another reason, rollback can still run for previously registered handlers, which execute in reverse step-start order.

Why? The step may have partially interacted with an external system before failing. For example, a payment provider may capture a charge, but the step may fail before returning the chargeId to Workflows. That is why rollback handlers receive output, but must handle output === undefined.

  1. Rollback only starts when the Workflow fails. Adding a rollback handler does not mean every step error triggers rollback. If user code catches an error and continues, the Workflow continues. Rollback starts when the Workflow itself is about to fail terminally.

When rollback starts, Workflows finds eligible step.do() calls, runs their rollback handlers, then records the final Workflow failure.

  1. Ordering has to be predictable. For sequential Workflows, rollback order feels obvious:

Reserve inventory.

Charge card.

Create shipment.

If shipment fails, refund the card and release the inventory.

Parallel steps make this more subtle. Completion order can differ from start order, so Workflows uses reverse step-start order instead of reverse completion order.

The practical rules are:

Any started or completed steps with rollback handlers are eligible.

The failing step.do() is also eligible if it registered a rollback handler.

Handlers run in reverse step-start order, not completion order.

How we designed the API

Once we had the expected behavior in mind, we had to add this new pattern into the Workflows API. Rollbacks went through a few iterations before we landed on rollback options.

Why not a fluent or builder API?

The first approach was a fluent form: step.do(...).rollback(...) It reads well. The forward action and the compensation sit next to each other, and the call site looks like ordinary JavaScript chaining.

The problem is that step.do() already has an important meaning: it starts a durable step and returns a Promise for the step output. In Workers, promise-like values are especially meaningful because Workers RPC supports promise pipelining, a pattern inherited from systems like Cap'n Proto.

Promise pipelining lets code call a method on a future value before that value has fully returned to the caller. For example:

const session = api.authenticate(apiKey);

const name = await session.whoami();

Here, session is not the real session object yet. It is more like a handle to the session that will exist soon. When you call session.whoami(), Workers can send that call to the remote side early and say: “once authentication creates the session, call whoami() on it.”

imageimage

That saves a round trip. The caller does not need to wait for authenticate() to fully finish before asking for whoami().

We considered a fluent API:

step.do("charge-card", chargeCard).rollback(refundCharge);

To a reader, that can look like “call .rollback() on the result of charge-card.”   But rollback is not part of the step’s output. It is part of the step.do() options, registered before the step starts, so Workflows knows how to compensate the step if a later step fails.

A fluent API also makes step timing harder to reason about. Today, step.do() starts the step when it is called, so developers can start a step, do other work, and await the first step later:

const first = step.do("first", () => serviceA.call());

await step.do("second", () => serviceB.call());

await first;

With today’s execution model, first starts immediately, before second. A fluent API would complicate that. Workflows would need to wait and see whether .rollback() gets attached before it knows the full step definition. That could delay when the step is sent to the engine.

In the earlier example, first could start at await first instead of at step.do("first", ...), after second has already completed.

That makes concurrent Workflows harder to reason about: step timing would depend on when the returned Promise is consumed, not just where step.do() is called.

We also considered a builder-style API:

const charge = await step

.saga("charge")

.do(() => chargeCard())

.rollback(() => refundCharge())

.run();

A builder API avoids the Promise ambiguity. It also gives us an obvious place for future step-level options, and makes it clear that the forward action and rollback action belong to the same saga step.

But it adds ceremony. Every step needs a final .run(), forgetting .run() would be easy and hard to spot without tooling, and simple one-step cases start to look like configuration chains. It also introduces a new step.saga() builder, breaking from the existing step.<action> pattern. Most importantly, it makes step.do() feel like an older API rather than the primary Workflows primitive. The goal of rollback was to extend step.do(), not replace it.

Rollback as step metadata

step.do(..., { rollback })

Ultimately, we chose the explicit form where rollback is metadata on the step.

This way, each rollback is defined within the forward step itself. Each handler receives the error that caused the rollback to start, the step context, and the output, which is either the persisted value returned by the forward step (which can be undefined) or undefined if the step failed before persisting a value.

Rollbacks emit lifecycle events, so you can tell whether compensation started, which rollback handler failed, and whether rollback completed successfully.

Crucially, the original Workflow failure remains separate: rollback is what Workflows does after the failure, not the reason the Workflow failed.

Just as you can define custom retry and timeout behavior in the step configuration via WorkflowStepConfig, you add rollback-specific values in rollbackConfig.

{

rollback: async ({ output }) => {

await bankA.credit({ accountId: fromAccountId, amount, transferId: ${transferId}-reversal });

},

rollbackConfig: {

retries: { limit: 10, delay: '30 seconds', backoff: 'exponential' },

timeout: '2 minutes',

},

}

This matches the lifecycle-event mental model we wanted. A step.do() already describes a durable unit of work that Workflows records, retries, and later shows in logs. Rollback is another lifecycle behavior for that same unit of work. It should travel with the step definition, not live in a separate wrapper or builder.

The step still starts when step.do() normally starts.

The returned promise still represents the step output.

Concurrent Workflow code keeps the same execution model.

Retry and timeout options for rollback live next to the rollback handler.

Existing step.do() calls keep working exactly as they do today.

This shape is slightly more explicit than the fluent API, but that explicitness is useful. The operation and its compensation are still in one place, and the API does not introduce a new step builder or a new kind of promise. Developers who already understand step.do() only need to learn one additional options object.

This is less magical, but it is simpler to adopt, and clearer to understand.

How it works under the hood

Rollback feels like a small API addition, but it changes what Workflows needs to record about each step.

A regular step.do() already has a durable record. Workflows records that the step started, whether it completed, what it returned, and whether it should be skipped instead of repeated if the Workflow resumes later.

Rollbacks add one more thing to that record: whether the step registered compensation logic.

This means Workflows has two pieces of information to bring together if the Workflow fails.

The first is durable step history. The Workflow engine stores data to know what ran, what completed, what output was saved, and whether rollback was registered.

The second is the rollback handler itself, which is the function written to compensate for that step. Workflows does not save the text of that function as data. Instead, it keeps a callable reference to the handler while the Workflow is running.

In Workers RPC, this kind of callable reference is called a stub. A stub lets one part of the system call code that is running somewhere else. Stubs also have lifetimes such that they can be disposed when a call or execution context ends. If you need to keep a stub past that point, Workers RPC provides a dup() method, which creates another handle to the same target.

For rollback, that model is useful. The durable step history records what needs compensation. The rollback stub gives Workflows a way to invoke the compensation code. And because rollback handlers may need to outlive the immediate step.do() call that registered them, Workflows keeps its own callable reference to the handler for the rollback phase.

In the common case, when a Workflow enters rollback in the same engine lifetime, Workflows already has the rollback stubs it needs. It can use the durable step history to find eligible steps, then invoke the rollback stubs that were registered during forward execution.

This gets more subtle when Workflows has to recover after a restart.

If the engine is evicted, crashes, or restarts while rollback is needed, Workflows still has the durable step history, but it may no longer have the in-memory rollback stubs. To recover, Workflows uses replay: a recovery mode where it can re-run the Workflow code without re-executing completed forward step bodies.

When replay reaches a completed step.do(), Workflows reads the persisted result instead of running the step body again. For rollback recovery, Workflows only needs to rebuild handlers for steps that had rollback attached and are eligible for rollback. As those step.do() calls are encountered, their rollback options can register the callable stubs again

That lets Workflows recover the rollback handlers it needs without duplicating the original external side effects.

imageimage

With those pieces in place, rollback can work whether the handler is still available in memory or has to be rebuilt during recovery.

When the workflow is about to fail, Workflows does not ask your application to reconstruct what happened. It already has the step history. It can look at the persisted record and answer the important questions:

Which steps started?

Which steps finished?

Which failed step may still need cleanup?

Which steps registered rollback handlers?

What output should each rollback handler receive?

What order should compensation run in?

Then Workflows invokes each rollback stub with a rollback context: the original error, the step context, and the step output, if one was persisted.

The ordering detail matters. In normal JavaScript, especially with Promise.all(), completion order is not always the same as start order. If step A starts first and step B starts second, step B might finish first. For rollback, Workflows uses the persisted start order as the stable source of truth, then unwinds it in reverse.

Rollback handlers also run through Workflows' normal step machinery. That means compensation gets the same operational properties you expect from Workflows: retries, timeouts, lifecycle events, logs, and a final recorded outcome. If a rollback handler keeps failing after its configured retries, Workflows records the rollback outcome as failed, stops running the remaining rollback handlers, and the Workflow instance ultimately ends in the Errored state.

This is the main difference between saga rollbacks and a catch block. A catch block only knows what is still in memory at its exact point in your JavaScript execution. Workflows rollback uses persisted step history to decide what already happened, invokes the stubs it already has in the common case, and safely rebuilds missing stubs during recovery when it needs to.

That is also why the API puts rollback on step.do() itself. Rollback is not a separate global error handler — it is metadata attached to the durable unit of work Workflows already understands.

What’s next

Our first iteration of rollbacks includes:

Explicit per-step rollback handlers for step.do()

Sequential rollback execution

Retry and timeout configuration for compensation

Next, we want to explore:

Rollback support for waitForEvent

Support for parallel rollback execution

Rollback support for Python Workflows

When a multi-step application fails halfway through, the hardest part is often not knowing that it failed. It is knowing what already happened, and what needs to happen next.

Saga rollbacks let you put that answer directly beside each step. If you are building multi-step applications with Workflows, try saga rollbacks and tell us what compensation patterns you want next. Get started with the Workflows documentation and share feedback in the Cloudflare Community.

この記事をシェア

関連記事

Cloudflare Blog★42026年6月24日 15:00

Cloudflare、すべてのアプリエコシステムでOAuthを解放

Cloudflare は、プラットフォーム上の開発者が他社製ツールと連携できるよう、全アプリエコシステムでの OAuth 利用を可能にする機能を発表した。これにより、顧客は独自に OAuth クライアントを作成・管理しやすくなる。

Cloudflare Blog★52026年6月24日 03:25

ポスト量子暗号化の行政命令は重要なマイルストーン、今こそ実務へ

トランプ大統領が署名した行政命令により、連邦機関は2030年までに最も機密性の高いシステムをポスト量子暗号化へ移行し、認証も2031年までに完了するよう義務付けられた。クラウドフレアはこの方針を歓迎している。

Cloudflare Blog★32026年6月23日 03:00

Cloudflare が Rust 製 HTTP ライブラリ「hyper」のバグを発見した方法

Cloudflare は、Workers エッジネットワークで画像処理サービスを実装する際に使用しているオープンソースライブラリ「hyper」にバグが存在することを確認し、その発見プロセスを公開しました。

今日のまとめ

AI日報で今日の重要ニュースをまとめ読み

ニュース一覧に戻る元記事を読む