Skip to content
Published on

Temporal Worker Versioning GA — リプレイモデルが生んだデプロイ問題、2世代を捨てて出てきた3つ目の答え

シェア
Authors

はじめに — 耐久実行の請求書はデプロイで届く

耐久実行(durable execution)エンジン全般の比較は、すでに5月のディープダイブで扱いました。本稿はその反対側、エンジンを一つだけ狭く深く掘ります — Temporalは2026年3月30日にWorker VersioningのGAを発表し、OSSサーバーではv1.31.0(2026年4月29日)がWorker Deployment APIのGAを明記しました。

「バージョニング機能のGA」と聞くとあくびが出るかもしれませんが、これはそういうニュースではありません。初プレビュー(2023年6月)からGAまで2年9か月かかり、その間に2世代のAPIがまるごと捨てられました。GA発表文の最初の段落で、ベンダー自身がこう認めています — 長く回るワークフローに新しいコードをデプロイすることは、耐久実行で常に最も厄介な問題の一つだった、と。なぜこの問題がそこまで難しいのかは、エンジンの中核設計、つまり決定論的リプレイから直ちに導かれます。まずそのメカニズムから見ましょう。

リプレイがデプロイを壊すメカニズム

Temporalワークフローの耐久性はこう働きます。ワークフローコードがアクティビティ実行・タイマー・子ワークフローといったコマンドを出すたびにサーバーがイベント履歴(event history)に記録し、ワーカーが死んだり入れ替わったりすると、新しいワーカーがその履歴を最初からリプレイしてコードの実行状態を復元します。この魔法が成立するには前提が一つ必要です — 同じ履歴に対してコードが常に同じコマンドを出すこと。公式ドキュメントはこれを明示的に要求しています。ワークフローコードは決定論的でなければならず、API呼び出しやDBクエリのような非決定作業はアクティビティに追い出すべきで、アクティビティはTemporalが自動でリトライしてくれる、と。

ところがこの前提は、コードが変わった瞬間に崩れます。

v1コード: A 実行 → タイマー → B 実行      (この順で履歴がすでに記録済み)
v2コード: A 実行 → C 実行 → タイマー → B  (新しいアクティビティ C を途中に追加)

v1で始まった実行をv2ワーカーがリプレイすると:
  履歴の次のイベントは「タイマー開始」なのに
  コードは「アクティビティ C をスケジュール」というコマンドを出す
  → 非決定論(non-determinism)エラー、ワークフロータスク失敗

短いワークフローしかないなら大した問題ではありません。デプロイ前に全部終わるからです。しかしTemporalの存在理由こそが、何日・何週間・何年も回るワークフローです。つまり「デプロイ時点で実行中のワークフローはない」という仮定が構造的に成立せず、リプレイという耐久性の源泉がそのままデプロイの地雷になります。これはTemporalのバグではなく、リプレイベースの耐久実行モデル全体が共有する制約です。

これまでの答え — パッチング、そしてそのコスト

この問題の伝統的な答えはパッチング(patching)です。ドキュメントの表現を借りればフィーチャーフラグに近いもので、コードの中に「この実行は当該変更より前に始まったか」を問う論理分岐を埋め込むわけです。

from datetime import timedelta
from temporalio import workflow


@workflow.defn
class MyWorkflow:
    @workflow.run
    async def run(self) -> None:
        if workflow.patched("use-new-activity"):
            # このパッチ以降に始まった実行
            self._result = await workflow.execute_activity(
                post_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )
        else:
            # パッチ以前に始まってまだ回っている実行 — 旧経路を維持
            self._result = await workflow.execute_activity(
                pre_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )

動きはします。問題は蓄積です。GA発表文が的確に突いています — ワークフローは何日・何週間・何年も回りうるので、パッチが実際のコード複雑度と認知負荷として積み上がる、と。分岐を消すには旧経路で回る実行がすべて終わったかを確認して非推奨化(deprecate)の手続きを踏む必要があり、これもまた手動です。要するにパッチングは、デプロイ問題をワークフローコード内の条件分岐に吸収させるやり方であり、変更の多いコードベースではその条件分岐がそのまま技術的負債になります。

Worker Versioningはアプローチを裏返します — バージョン分岐をコードに埋め込む代わりに、デプロイ単位でワーカーをまるごとバージョニングし、実行中のワークフローを開始したバージョンのワーカーに残しておくのです。アイデアは単純なのに、この単純なアイデアのAPIを確定するのに3年かかりました。

3年、3世代 — バージョニングAPI廃止の年代記

リリースノートを時系列で追うと、この機能の難度がそのまま見えます。

2023-06-23  v1.21.0  V1プレビュー: Build IDを「バージョンセット」で束ねる方式
                     UpdateWorkerBuildIdCompatibility / GetWorkerTaskReachability
2024-05-31  v1.24.0  V1のバージョンセット概念を廃止と宣言
                     → V2「バージョニングルール」(assignment rules)へ置換、実験段階
2024-12     (プレリリース) Deployment ベースの3つ目の設計が登場
2025-06-27  v1.28.0  Worker Deployment API が公開プレビューへ昇格
                     V1・V2 API を一括 deprecated
                     2024-12 プレリリースAPIはサポート終了
2026-03-30  ブログ   GA を正式発表(全SDK対象)
2026-04-29  v1.31.0  OSSサーバーでGAを明記、V1・V2は sunset
                     → 次期バージョン v1.32.0 での削除を予告

一つずつ辿るとこうです。v1.21.0(2023年6月)が初プレビューでした — ワーカーにBuild IDを付け、どのビルド同士が互換かをUpdateWorkerBuildIdCompatibilityで「バージョンセット」に登録する方式。v1.24.0(2024年5月)はリリースノートに、バージョンセットという概念そのものが廃止され、より柔軟な「バージョニングルール」へ置き換わると書きました。そしてv1.28.0(2025年6月)のノートでは、「2024年12月プレリリース」として出ていたDeploymentベースのAPI群(DescribeDeploymentなど)までサポート終了と表記され、いまのWorker Deployment APIが公開プレビューに上がります。このときV1・V2 APIが一斉にdeprecatedとなり、v1.27.xでバージョニングを使っていたユーザーには、アップグレード前に既存のDeploymentをすべて削除せよという互換性破壊の告知まで付きました。

そしてv1.31.0(2026年4月)がSetWorkerDeploymentCurrentVersionSetWorkerDeploymentRampingVersionDescribeWorkerDeploymentなど9個のAPIをGAと宣言し、V1・V2 APIは次のサーバーバージョンであるv1.32.0で削除されると予告しました(本稿執筆時点の最新サーバーはv1.31.2で、削除はまだ執行前です)。興味深いのは、GAと同時にCreateWorkerDeploymentのような実験段階のAPIが新たに追加された点です — 表面がいまだ動いているという意味です。

ここから二つ読み取れます。第一に、リプレイモデルの上で「安全なデプロイ」のAPIを設計する仕事は、一つのベンダーが二度作り直すほど難しい。第二に、実務的な警告 — self-hostedでV1(build ID互換性)やV2(バージョニングルール)APIの上にデプロイ自動化を積んであるなら、マイグレーションは選択ではなく、v1.32.0という締め切りのある作業です。

GAされたモデル — Deployment、Pinned、Auto-Upgrade

生き残った3つ目の設計の構造はこうです(概念ドキュメント基準)。

Worker Deploymentはサービス単位の論理的なまとまりで(名前を持ちます)、Worker Deployment Versionはそのサービスの一イテレーションで、デプロイ名 + Build IDで識別されます。ワーカーはポーリングを始めるときに自分のバージョンをサーバーへ報告します。ワーカー側の設定はこんな見た目です(プロダクションガイドのPython例)。

from temporalio.common import WorkerDeploymentVersion, VersioningBehavior
from temporalio.worker import Worker, WorkerDeploymentConfig

worker = Worker(
    client,
    task_queue="mytaskqueue",
    workflows=workflows,
    activities=activities,
    deployment_config=WorkerDeploymentConfig(
        version=WorkerDeploymentVersion(
            deployment_name="llm_srv", build_id=my_env.build_id
        ),
        use_worker_versioning=True,
        default_versioning_behavior=VersioningBehavior.UNSPECIFIED,
    ),
)

核心は、ワークフロータイプごとに宣言するVersioning Behaviorの2種類です。

@workflow.defn(versioning_behavior=VersioningBehavior.PINNED)
class OrderWorkflow:
    ...
  • Pinned — 始まったバージョンで最後まで実行されます。ドキュメントの保証文言そのままに、単一のWorker Deployment Versionで完了することが保証されます。実行中にコードが変わることがないので、そのワークフローにパッチングは不要です。どうしても移したいならtemporal workflow update-optionsで手動移動します。
  • Auto-Upgrade — currentバージョンが変われば自動で追随します。代わりにドキュメントが明示するとおり複数のバージョンを行き来するので、パッチングでリプレイ安全性を手動で維持しなければなりません。バージョニングがパッチングを置き換えるのではなく、pinnedに限って免除してくれるということです。

ルーティングは三つの概念で回ります。新しいワークフローが向かうCurrent Version、トラフィックの一部(0〜100%、ワークフローIDを基準にグループが分かれます)を先に受けるRamping Version、そして各実行が次に乗るTarget Version。カナリアのように新バージョンへ5%だけ流してみて、問題がなければcurrentへ昇格させ、問題が起きればcurrentを前のバージョンへ戻します — 旧バージョンのワーカーがまだポーリング中なので、ロールバックは即座です。

バージョンの生涯は Inactive → Active → Draining(current/rampingから降りたがpinnedワークフローが残っている) → Drained(残ったpinnedがすべて終了した)と流れます。継承ルールもドキュメント化されています — pinnedな親の子は(同じデプロイのタスクキューであれば)親のバージョンを引き継ぎ、auto-upgradeは何も継承せず、cronは絶対に継承しません。Continue-as-Newはpinnedバージョンをチェーンの先まで引き継ぎます。

バージョン要件はなかなか手強いです。ドキュメント基準でサーバー v1.29.1+、CLI v1.4.1+、SDKは Go v1.35.0 / Python 1.11 / Java 1.29 / TypeScript 1.12 / .NET 1.7.0 / Ruby 0.5.0 以上です。

とても長いワークフローに残る穴 — Upgrade on Continue-as-New

pinnedとauto-upgradeでもきれいに片付かないケースが一つ残ります。GA発表文が直接挙げる例で、何か月も回るエンティティワークフローや、相互作用の合間に何週間も眠るAIエージェントのような、事実上終わらないワークフローです。pinnedにすればそのバージョンに永遠に縛られ、auto-upgradeにすればパッチング地獄へ逆戻りします。

GAと同時に公開プレビューとして出たUpgrade on Continue-as-Newが、この穴を狙います。多くの長寿ワークフローはすでにContinue-as-Newをチェックポイントとして使っているので、その境界をバージョンアップグレードの地点として使おう、というわけです — 各runはpinnedで回り(run中のパッチは不要)、新しいバージョンがcurrent/rampingになるとGetTargetWorkerDeploymentVersionChanged(ワークフロータスクが終わるたびに更新されます)で検知し、次のContinue-as-Newで新バージョンへ乗り換えます。各runは一つのバージョンの中で終わりますが、論理的なワークフローは複数のバージョンを横断します。v1.31.0にはrampingバージョンへつなぐ動作も追加されました。ただし、この部分はまだ公開プレビューだという点を踏まえる必要があります。

正直なトレードオフ

GAという判子に騙されてタダ飯として読んではいけません。請求書はこんな見た目です。

第一に、pinnedはレインボーデプロイであり、レインボーデプロイはインフラコストです。 pinnedワークフローが残っている限り、そのバージョンのワーカーを回し続けなければなりません。ドキュメント自身が、レインボーデプロイでは同時に二つ以上のアクティブなバージョンが回ると言っています。何か月ものpinnedワークフローは何か月ものワーカーfleetを意味し、デプロイするたびにバージョンが一つずつ増えるので、運用対象もその分だけ増えます。KubernetesならGAとなったTemporal Worker Controllerがバージョン別fleet管理を肩代わりしてくれますが、コンピュートコストそのものが消えるわけではありません。

第二に、auto-upgradeはパッチングから解放されません。 上で引用したドキュメントの文言そのままです。バージョニングを入れても、auto-upgradeワークフローのコード変更は依然としてリプレイ安全性のレビューとパッチマーカーを要求します。「GAされたのでpatched呼び出しを全部消してよい」という結論は誤りです。

第三に、バージョンは無限に積めません。 v1.28.0リリースノートの運用ノブ基準で、deploymentあたりのバージョン数上限のデフォルトは100で、ノートは数百以上に上げるのは安全ではないと警告します(ネームスペースあたりのdeployment数のデフォルト100は大きく上げても安全だとしているのと対照的です)。drainが終わらないpinned実行が積み上がると、バージョン衛生 — 古いバージョンを空にして消す作業 — が新しい運用業務になります。drainage状態の更新もデフォルト3分周期なので、リアルタイムではありません。

第四に、導入はデプロイパイプラインの統合作業です。 バージョニングの半分はサーバーではなくあなたのCDです — ビルドごとにBuild IDを刻み、デプロイ後にSetWorkerDeploymentRampingVersionSetWorkerDeploymentCurrentVersionを呼ぶステップがパイプラインに入らなければなりません。上の最小バージョンマトリクス(サーバー・CLI・SDKすべて)も満たす必要があります。

第五に、使わなくてよい場合が明確にあります。 すべてのワークフローが短くてデプロイ間隔の中で終わるなら — タスクキューを自然に空にしてデプロイできるなら — unversionedなキューで十分です。ワークフローコードの変更がまれなら、patchedがいくつかある方がバージョン別ワーカーfleetの運用より安上がりです。この機能の損益分岐点は、「デプロイの合間に終わらないワークフロー × コード変更の頻度」が十分に大きいときです。

同じリリースの残り — エンジンが向かう方向

文脈のために、v1.31.0に一緒に載ったものも押さえておきます。サーバーがタスクキューを見てAWS Lambdaワーカーを直接invoke/scaleするServerless Workersがプレリリースで入り(Worker Controller Instanceという新しいサーバーコンポーネント、デフォルト無効)、ワークフローではないサーバー内部のステートマシンを一般化するCHASMフレームワークがデフォルト有効になり(その上のアプリケーションはまだオフ)、ワークフローなしでアクティビティだけを単独実行するStandalone Activitiesが公開プレビューへ、タスクキューの優先度/公平性はGAへ上がりました。Nexusはもうフィーチャーフラグなしで常時有効です。ワークフローリプレイマシンから汎用の耐久実行プラットフォームへ — リリースノートが指す方向は一貫しています。

それであなたは使うべきか

判断基準を三つの質問に圧縮するとこうです。

  • デプロイの合間に終わらないワークフローが実際にあるか? ないならここで終わり — unversionedで生きてください。
  • デプロイ時に非決定論エラーを実際に食らったか、あるいはpatched分岐がコードレビューで揉め続けているか? ならばpinnedをデフォルトにすることで、その痛みがデプロイトポロジーの問題に置き換わります。
  • バージョン別ワーカーfleetを回すインフラの余力があるか? rainbowデプロイのコンピュートコストとバージョン衛生の業務を負担できないなら、いっそワークフローを短く割ってContinue-as-Newの周期を詰める方がましかもしれません。

一つの錯覚だけ警戒すれば十分です — バージョニングはデプロイ問題を解くのであって、リトライ問題を解くわけではありません。アクティビティは依然としてTemporalがリトライし、その副作用(決済、メール、チケット)の冪等性は依然としてあなたの担当です。この話はAIエージェントはプロダクションでどう失敗するのか — 14の失敗モード、そしてリトライが安全ではない理由Exactly-onceは幻想か:決済・メッセージングの正確性で詳しく扱いました。とくに何週間も眠るAIエージェントを耐久ワークフローで包もうとしているチームなら、今回のGAのUpgrade on Continue-as-Newがまさにそのシナリオを狙っているという点と一緒に、それがまだプレビューだという点も覚えておいてください。

おわりに

まとめるとこうです。決定論的リプレイは耐久実行の源泉ですが、その代価はデプロイで請求されます — 履歴とコードが食い違った瞬間に非決定論エラーが出るからです。これまではその代価をワークフローコード内のパッチ分岐が吸収してきましたが、Worker Versioning GAはそれをデプロイトポロジー — バージョン別ワーカーfleetとルーティングルール — へ移しました。制約が消えたのではなく、場所を移しただけです。コードがきれいになる代わりにインフラが厚くなります。

そしてこの答えが出るまでに一つのベンダーが3年かけて2世代のAPIを捨てたという事実そのものが、リプレイモデルの上で安全なデプロイがどれほど本質的に難しい問題かについての、最も正直な証言でしょう。導入するかどうかに関係なく、耐久実行エンジンを選ぶ人なら「このエンジンは実行中のワークフローがあるままコードをどう変えさせてくれるのか」を最初の質問リストに載せておいてください。

参考資料