Skip to content

Split View: Temporal Worker Versioning GA — 리플레이 모델이 만든 배포 문제, 두 세대를 버리고 나온 세 번째 답

|

Temporal Worker Versioning GA — 리플레이 모델이 만든 배포 문제, 두 세대를 버리고 나온 세 번째 답

들어가며 — 내구 실행의 청구서는 배포에서 날아온다

내구 실행(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개월이 걸렸고, 그 사이 두 세대의 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월)이 SetWorkerDeploymentCurrentVersion, SetWorkerDeploymentRampingVersion, DescribeWorkerDeployment 등 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

살아남은 세 번째 설계의 구조는 이렇습니다(개념 문서 기준).

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 두 가지입니다.

@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는 아무것도 상속하지 않으며, 크론은 절대 상속하지 않습니다. 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이 재시도하고, 그 부작용(결제, 메일, 티켓)의 멱등성은 여전히 당신 몫입니다. 이 이야기는 에이전트의 프로덕션 실패 분류와 멱등성 편과 Exactly-once는 환상인가 편에서 자세히 다뤘습니다. 특히 몇 주씩 잠드는 AI 에이전트를 내구 워크플로로 감싸려는 팀이라면, 이번 GA의 Upgrade on Continue-as-New가 정확히 그 시나리오를 겨냥하고 있다는 점과 함께, 그것이 아직 미리보기라는 점도 같이 기억해 두세요.

마치며

정리하면 이렇습니다. 결정론적 리플레이는 내구 실행의 원천이지만, 그 대가는 배포에서 청구됩니다 — 이력과 코드가 어긋나는 순간 비결정론 에러가 나니까요. 지금까지는 그 대가를 워크플로 코드 안의 패치 분기가 흡수했고, Worker Versioning GA는 그것을 배포 토폴로지 — 버전별 워커 fleet과 라우팅 규칙 — 로 옮겼습니다. 제약이 사라진 게 아니라 자리를 옮긴 겁니다. 코드가 깨끗해지는 대신 인프라가 두꺼워집니다.

그리고 이 답이 나오기까지 한 벤더가 3년간 두 세대의 API를 버렸다는 사실 자체가, 리플레이 모델 위에서 안전한 배포가 얼마나 본질적으로 어려운 문제인지에 대한 가장 정직한 증언일 겁니다. 도입 여부와 무관하게, 내구 실행 엔진을 고르는 사람이라면 "이 엔진은 실행 중인 워크플로가 있는 채로 코드를 어떻게 바꾸게 해 주는가"를 첫 질문 목록에 올려 두세요.

참고 자료

Temporal Worker Versioning GA — The Deploy Problem Replay Created, and the Third Answer After Two Discarded Generations

Introduction — Durable Execution Bills You at Deploy Time

A broad comparison of durable execution engines is already covered in May's deep dive. This post is the opposite: one engine, narrow and deep — Temporal announced GA for Worker Versioning on March 30, 2026, and on the OSS server, v1.31.0 (April 29, 2026) spelled out GA for the Worker Deployment API.

"Versioning feature goes GA" might sound like a yawn, but this is not that kind of news. It took two years and nine months from the first preview (June 2023) to GA, and along the way two full generations of API were thrown away. In the very first paragraph of the GA announcement the vendor admits it themselves — deploying new code to long-running workflows has always been one of the hardest problems in durable execution. Why that problem is so hard follows directly from the engine's core design: deterministic replay. Let us start with that mechanism.

The Mechanism by Which Replay Breaks Deploys

The durability of a Temporal workflow works like this. Every time workflow code issues a command — run an activity, set a timer, start a child workflow — the server records it in the event history, and when a worker dies or is replaced, the new worker replays that history from the beginning to restore the code's execution state. For this magic to hold, one premise is required: given the same history, the code must always issue the same commands. The official docs require this explicitly: workflow code must be deterministic, non-deterministic work like API calls or DB queries must be pushed out into activities, and Temporal will retry activities for you.

But that premise collapses the moment the code changes.

v1 code: run A → timer → run B          (history already recorded in this order)
v2 code: run A → run C → timer → run B  (new activity C inserted in the middle)

When a v2 worker replays an execution started on v1:
  the next event in the history is "timer started"
  but the code issues a "schedule activity C" command
  → non-determinism error, workflow task failure

If you only have short workflows, this is barely a problem. They all finish before the deploy. But the entire reason Temporal exists is workflows that run for days, weeks, years. Which means the assumption "there are no workflows running at deploy time" is structurally false, and replay, the very source of durability, becomes the landmine in your deploy. This is not a Temporal bug — it is a constraint shared by the whole family of replay-based durable execution models.

The Answer So Far — Patching, and Its Cost

The traditional answer to this problem is patching. To borrow the docs' phrasing, it is similar to a feature flag: you plant a logical branch inside the code that asks "did this execution start before this change?"

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"):
            # executions that started after this patch
            self._result = await workflow.execute_activity(
                post_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )
        else:
            # executions that started before the patch and are still running — keep the old path
            self._result = await workflow.execute_activity(
                pre_patch_activity,
                schedule_to_close_timeout=timedelta(minutes=5),
            )

It works. The problem is accumulation. The GA announcement puts a finger on it exactly — because workflows can run for days, weeks, or years, patches pile up as real code complexity and cognitive burden. To delete a branch you have to confirm that every execution on the old path has finished and then walk it through a deprecation procedure — which is also manual. In short, patching is the approach that absorbs the deploy problem into conditionals inside workflow code, and in a codebase that changes often, those conditionals are technical debt.

Worker Versioning flips the approach — instead of planting version branches inside the code, you version whole workers by deploy unit and leave running workflows on the worker version that started them. The idea is simple. Pinning down the API for this simple idea took three years.

Three Years, Three Generations — A Chronicle of Versioning API Deprecation

Follow the release notes in chronological order and the difficulty of this feature is right there on the page.

2023-06-23  v1.21.0  V1 preview: grouping Build IDs into "version sets"
                     UpdateWorkerBuildIdCompatibility / GetWorkerTaskReachability
2024-05-31  v1.24.0  V1's version set concept declared deprecated
                     → replaced by V2 "versioning rules" (assignment rules), experimental
2024-12     (pre-release) a third, Deployment-based design appears
2025-06-27  v1.28.0  Worker Deployment API promoted to public preview
                     V1 and V2 APIs deprecated together
                     the 2024-12 pre-release APIs go unsupported
2026-03-30  blog     GA officially announced (across all SDKs)
2026-04-29  v1.31.0  OSS server spells out GA, V1 and V2 sunset
                     → removal announced for the next version, v1.32.0

Taken one at a time, here is how it went. v1.21.0 (June 2023) was the first preview — you tagged workers with a Build ID and registered which builds were compatible with each other into a "version set" via UpdateWorkerBuildIdCompatibility. v1.24.0 (May 2024) wrote in its release notes that the version set concept itself was deprecated and replaced by more flexible "versioning rules". And the notes for v1.28.0 (June 2025) mark even the Deployment-based APIs that had shipped as the "December 2024 pre-release" (DescribeDeployment and friends) as unsupported, while today's Worker Deployment API rises to public preview. That is when the V1 and V2 APIs were deprecated all at once, and users who had been using versioning on v1.27.x even got a breaking-change notice telling them to delete all existing Deployments before upgrading.

Then v1.31.0 (April 2026) declared nine APIs GA — SetWorkerDeploymentCurrentVersion, SetWorkerDeploymentRampingVersion, DescribeWorkerDeployment and others — and announced that the V1 and V2 APIs will be removed in the next server version, v1.32.0 (as of this writing the latest server is v1.31.2, so the removal has not yet been carried out). What is interesting is that experimental APIs like CreateWorkerDeployment were newly added at the same time as GA — meaning the surface is still moving.

Two things can be read out of this. First, designing an API for "safe deploys" on top of a replay model is hard enough that one vendor tore it up twice. Second, a practical warning — if you are self-hosted and have built deploy automation on top of the V1 (build ID compatibility) or V2 (versioning rules) APIs, migration is not optional; it is work with a deadline named v1.32.0.

The GA'd Model — Deployment, Pinned, Auto-Upgrade

Here is the structure of the third design, the one that survived (per the concepts doc).

A Worker Deployment is a logical grouping at the service level (it has a name), and a Worker Deployment Version is one iteration of that service, identified by deployment name plus Build ID. Workers report their own version to the server when they start polling. The worker-side configuration looks like this (the Python example from the production guide).

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,
    ),
)

The core is the two Versioning Behavior options you declare per workflow type.

@workflow.defn(versioning_behavior=VersioningBehavior.PINNED)
class OrderWorkflow:
    ...
  • Pinned — runs to completion on the version it started on. In the docs' own guarantee wording, it is guaranteed to complete on a single Worker Deployment Version. Since the code cannot change mid-execution, that workflow needs no patching. If you really must move one, you do it by hand with temporal workflow update-options.
  • Auto-Upgrade — automatically follows along when the current version changes. In exchange, as the docs make explicit, it moves across multiple versions, so you must manually maintain replay safety with patching. Versioning does not replace patching; it exempts you from it for pinned only.

Routing runs on three concepts. The Current Version, where new workflows go; the Ramping Version, which gets a slice of traffic first (0–100%, with groups split by workflow ID); and the Target Version, the one each execution will move onto next. Like a canary you send 5% to the new version, and if nothing breaks you promote it to current; if something does, you roll current back to the previous version — and because the old version's workers are still polling, the rollback is immediate.

A version's lifecycle flows Inactive → Active → Draining (taken down from current/ramping but pinned workflows remain) → Drained (all remaining pinned ones have finished). The inheritance rules are documented too — a pinned parent's children inherit the parent version (if it is a task queue in the same deployment), auto-upgrade inherits nothing, and cron never inherits. Continue-as-New carries the pinned version across the chain.

The version requirements are not trivial. Per the docs: server v1.29.1+, CLI v1.4.1+, and SDKs at Go v1.35.0 / Python 1.11 / Java 1.29 / TypeScript 1.12 / .NET 1.7.0 / Ruby 0.5.0 or above.

The Remaining Hole for Very Long Workflows — Upgrade on Continue-as-New

One case is left that neither pinned nor auto-upgrade handles cleanly. The example the GA announcement gives directly: workflows that effectively never end, like entity workflows running for months, or AI agents that sleep for weeks between interactions. Leave them pinned and they are bound to that version forever; leave them auto-upgrade and you are back in patching hell.

Upgrade on Continue-as-New, released as public preview alongside GA, targets that hole. Many long-lived workflows already use Continue-as-New as a checkpoint, so the pitch is to use that boundary as the version upgrade point — each run executes pinned (no patching needed within a run), and when a new version becomes current/ramping, it is detected via GetTargetWorkerDeploymentVersionChanged (refreshed at the end of every workflow task) and the workflow switches to the new version at the next Continue-as-New. Each run finishes within one version, but the logical workflow crosses several. v1.31.0 also added the behavior of continuing onto a ramping version. Bear in mind, though, that this part is still public preview.

The Honest Tradeoffs

Do not let the GA stamp fool you into reading this as a free lunch. The bill looks like this.

First, pinned means rainbow deployments, and rainbow deployments are infrastructure cost. As long as pinned workflows remain, you have to keep that version's workers running. The docs themselves say that in a rainbow deployment two or more active versions run at the same time. A months-long pinned workflow means a months-long worker fleet, and since each deploy adds one more version, your operational surface grows accordingly. On Kubernetes, the Temporal Worker Controller, now GA, manages per-version fleets for you — but the compute cost itself does not disappear.

Second, auto-upgrade does not free you from patching. Exactly as the doc wording quoted above says. Even with versioning on, code changes to auto-upgrade workflows still demand replay-safety review and patch markers. The conclusion "it is GA now, so I can delete all my patched calls" is wrong.

Third, versions cannot pile up forever. Per the operational knobs in the v1.28.0 release notes, the default cap on versions per deployment is 100, and the notes warn that raising it beyond a few hundred is not safe (in contrast to the default of 100 deployments per namespace, which they say is safe to raise substantially). If pinned executions that never finish draining accumulate, version hygiene — emptying and deleting old versions — becomes a new operational job. Drainage status refresh is also on a 3-minute cycle by default, so it is not real time.

Fourth, adoption is a deploy-pipeline integration project. Half of versioning is not the server, it is your CD — stamping a Build ID on every build, and adding steps to your pipeline that call SetWorkerDeploymentRampingVersion and SetWorkerDeploymentCurrentVersion after deploy. You also have to satisfy the minimum version matrix above (server, CLI, and SDK alike).

Fifth, there are clear cases where you should not use it. If all your workflows are short enough to finish inside the deploy interval — if you can drain the task queue naturally and then deploy — an unversioned queue is enough. If workflow code changes are rare, a handful of patched calls is cheaper than operating per-version worker fleets. The break-even point for this feature is when "workflows that do not finish between deploys × frequency of code change" is large enough.

The Rest of the Same Release — Where the Engine Is Headed

For context, here is what else shipped in v1.31.0. Serverless Workers, where the server watches the task queue and directly invokes/scales AWS Lambda workers, landed as a pre-release (with a new server component called the Worker Controller Instance, disabled by default); the CHASM framework, which generalizes the server's internal state machines beyond workflows, was enabled by default (the applications on top of it are still off); Standalone Activities, which run activities on their own without a workflow, went to public preview; and task queue priority/fairness went GA. Nexus is now always on, with no feature flag. From workflow replay machine to general-purpose durable execution platform — the direction the release notes point in is consistent.

So Should You Use It

Compressed into three questions, the criteria are these.

  • Do you actually have workflows that do not finish between deploys? If not, it ends here — live unversioned.
  • Have you actually hit non-determinism errors at deploy time, or do patched branches keep drawing fire in code review? Then a pinned default converts that pain into a deploy topology problem.
  • Do you have the infrastructure headroom to run per-version worker fleets? If you cannot absorb the compute cost of rainbow deployments and the version hygiene work, you may be better off slicing workflows shorter and tightening the Continue-as-New cycle.

There is only one misconception to guard against — versioning solves the deploy problem, not the retry problem. Temporal still retries activities, and the idempotency of their side effects (payments, email, tickets) is still on you. That story is covered in detail in Agent Production Failure Taxonomy and Idempotency and The Truth About Exactly-Once Semantics. Especially if your team is trying to wrap an AI agent that sleeps for weeks in a durable workflow, remember both that this GA's Upgrade on Continue-as-New targets exactly that scenario — and that it is still a preview.

Closing

To sum up. Deterministic replay is the source of durable execution, but the price is billed at deploy time — because the moment history and code diverge, you get a non-determinism error. Until now that price was absorbed by patch branches inside workflow code, and Worker Versioning GA moved it into deploy topology — per-version worker fleets and routing rules. The constraint did not disappear; it moved. Your code gets cleaner and your infrastructure gets thicker.

And the fact that it took one vendor three years and two discarded generations of API to arrive at this answer is probably the most honest testimony there is about how intrinsically hard safe deploys are on top of a replay model. Whether or not you adopt it, if you are choosing a durable execution engine, put "how does this engine let me change code while workflows are running?" on your first list of questions.

References