You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add support for per-task version routing — the ability to register multiple implementations of the same logical orchestration or activity name (differentiated by version) in a single worker process. This complements the existing worker-level versioning (UseVersioning()) which pins the entire worker to one version.
Scope: Orchestrations and activities. Entity versioning is out of scope for this proposal.
Motivation
Today, orchestration versioning in durabletask-dotnet works at the worker level: you set UseVersioning() with a single version string, and the worker only accepts work items matching that version. To upgrade orchestration logic, you deploy a new worker binary.
This model works well for simple rolling deployments, but it doesn't address scenarios where:
Multiple versions must be active simultaneously — e.g., existing instances continue on v1 while new instances start on v2
Version routing should be per-orchestration — e.g., OrderWorkflow is on v2 but PaymentWorkflow is still on v1
Activity routing sometimes needs to be more specific than orchestration inheritance — e.g., a v2 orchestration must explicitly call a v1 activity during a staged migration or compatibility window
Eternal orchestrations need safe migration — long-running orchestrations need a replay-safe boundary to change versions without history conflicts
Real-world example
A team has an eternal MonitorWorkflow orchestration that runs 24/7. They need to change its logic. With worker-level versioning, they must coordinate a full worker swap. With per-orchestrator versioning, they deploy both v1 and v2 in the same worker, let existing instances drain on v1, and use ContinueAsNew(new ContinueAsNewOptions { NewVersion = "2" }) to migrate individual instances at a safe point.
Why this complements worker-level versioning
Worker-level versioning is still the right tool when versioning is primarily a deployment concern. It lets one deployment stamp and accept one version, which keeps straightforward rolling upgrades simple when a deployment runs exactly one implementation of each task.
Per-task versioning solves a different problem: task routing inside a worker. It lets one worker host v1 and v2 of OrderWorkflow, keep PaymentWorkflow on v1, and route activity implementations alongside the currently executing orchestration version. It also allows an orchestration to override that default inheritance when a specific activity version must be called explicitly. That avoids introducing extra worker pools just to express which implementation should run for a particular logical task.
These strategies are complementary, not replacements for one another:
Worker-level versioning = "one deployment, one version"
Per-task versioning = "one worker, multiple live task versions"
Proposed API
1. [DurableTaskVersion] attribute
A new attribute to declare the version of a class-based orchestrator or activity:
// Orchestrators — multiple versions of the same logical name[DurableTask("OrderWorkflow")][DurableTaskVersion("1")]publicclassOrderWorkflowV1:TaskOrchestrator<int,string>{ ...}[DurableTask("OrderWorkflow")][DurableTaskVersion("2")]publicclassOrderWorkflowV2:TaskOrchestrator<int,string>{ ...}// Activities — same pattern[DurableTask("ProcessPayment")][DurableTaskVersion("1")]publicclassProcessPaymentV1:TaskActivity<PaymentRequest,PaymentResult>{ ...}[DurableTask("ProcessPayment")][DurableTaskVersion("2")]publicclassProcessPaymentV2:TaskActivity<PaymentRequest,PaymentResult>{ ...}
Multiple classes can share the same [DurableTask] name as long as each has a unique [DurableTaskVersion] value.
Recommended version string format
The Durable Task Scheduler (DTS) backend validates the orchestration version field as Major[.Minor[.Patch]] semver (for example 1, 1.2, or 1.2.3). For maximum portability and forward compatibility, use semver-compatible version strings such as "1", "1.0", or "2.0.1".
The SDK itself does not validate the contents of the [DurableTaskVersion] attribute or TaskVersion strings — values are passed through to the backend as-is. Non-semver values (for example, "v1" or "alpha") may work against backends that do not enforce a format but will be rejected by DTS at scheduling time. This is intentional: the SDK stays backend-agnostic, but the recommendation above keeps you compatible with all supported backends.
The SDK does reject the obviously-broken case: [DurableTaskVersion(" ")] (whitespace-only) is rejected at compile time by source generator diagnostic DURABLE3005, at TaskVersion construction time, and at AddOrchestrator / AddActivity registration. Pass an unversioned task by omitting the attribute entirely, by passing null / "", or by using TaskVersion.Unversioned on the manual registration overloads.
2. Source generator changes
When the generator detects multiple orchestrators or activities sharing the same task name with different versions, it produces version-qualified helper methods instead of the unqualified ones:
// Generated orchestrator helpers — version is embedded in the method name and auto-stampedclient.ScheduleNewOrderWorkflow_1InstanceAsync(input);client.ScheduleNewOrderWorkflow_2InstanceAsync(input);// Sub-orchestrator equivalentscontext.CallOrderWorkflow_1Async(input);context.CallOrderWorkflow_2Async(input);// Generated activity helperscontext.CallProcessPayment_1Async(request);context.CallProcessPayment_2Async(request);
For orchestrator and sub-orchestrator helpers, a private ApplyGeneratedVersion() helper ensures the correct version is set on StartOrchestrationOptions.Version or SubOrchestrationOptions.Version without requiring the caller to manage it manually.
For activities, generated version-qualified helpers stamp ActivityOptions.Version when the caller has not already provided one. This makes CallProcessPayment_1Async(...) and CallProcessPayment_2Async(...) true explicit selectors instead of compile-time aliases only.
If the caller passes a non-null options.Version (or ActivityOptions.Version) that disagrees with the version baked into the generator-emitted helper name — including the explicit-unversioned case (TaskVersion.Unversioned) — the helper throws InvalidOperationException at runtime rather than silently routing to a different version than the method name advertises. To call a different version, use the unqualified ScheduleNewOrchestrationInstanceAsync / CallSubOrchestratorAsync / CallActivityAsync overload.
For single-version orchestrators and activities, the unqualified helper is generated as before for backward compatibility. For single-version activities with [DurableTaskVersion], the unqualified helper also stamps the declared activity version.
AddAllGeneratedTasks() registers all versioned implementations automatically.
The generated method name suffix is constructed by escaping every non-alphanumeric character (including _) as _xHHHH_, which makes it injective — distinct version strings always produce distinct method names.
3. Manual registration API
For non-class-based scenarios, overloads of AddOrchestrator and AddActivity accept TaskVersion:
Whitespace-only version strings are rejected by the TaskVersion constructor with ArgumentException. Pass TaskVersion.Unversioned (or default(TaskVersion), or use the existing name-only overloads) to register an unversioned default.
4. Explicit activity version selection
A new ActivityOptions : TaskOptions public type exposes a TaskVersion? Version property:
// Explicit v2awaitcontext.CallActivityAsync<PaymentResult>("ProcessPayment",request,newActivityOptions{Version="2"});// Explicit unversioned (call the unversioned ProcessPayment from a versioned orchestration)awaitcontext.CallActivityAsync<PaymentResult>("ProcessPayment",request,newActivityOptions{Version=TaskVersion.Unversioned});// Inherit (the default — same as plain TaskOptions)awaitcontext.CallActivityAsync<PaymentResult>("ProcessPayment",request);
ActivityOptions.Version is tri-state:
null — inherit the orchestration instance version (default).
TaskVersion.Unversioned (i.e., default(TaskVersion)) — explicitly request the unversioned activity.
new TaskVersion("X") — explicitly request version X.
Any non-null Version is treated as an explicit selection; the value of the inner string only determines which registration the worker looks up. TaskVersion's constructor normalizes null and string.Empty to the Unversioned form, and the struct's equality / hash treat the two as identical, so TaskVersion.Unversioned == new TaskVersion("") and using either as a dictionary key works.
5. Worker dispatch
The worker's internal factory resolves implementations using (TaskName, TaskVersion).
Orchestrator dispatch
When a work item arrives for an orchestration instance:
Exact match — if a registration exists for the requested (name, version), use it.
Unversioned compatibility fallback — only when no versioned registration exists for the same logical name, fall back to the unversioned registration. This preserves the migration path where pre-versioning instances scheduled with a specific version need to dispatch against a not-yet-migrated registry.
Not found — return failure.
The fallback is intentionally narrow. If the registry has any versioned registration for the name (e.g., v1, v2), a request for v3 returns "not found" instead of silently routing to a coexisting unversioned default the caller did not ask for.
Activity dispatch
Activity routing depends on whether the version was explicit (caller-supplied via ActivityOptions.Version) or inherited (the default). The two paths differ on whether the unversioned-fallback step is allowed:
Caller's intent
Step 1
Step 2
Step 3
Explicit activity version
Exact match on (name, version)
(no fallback)
Not found
Inherited activity version
Exact match on (name, version)
Unversioned fallback (only when no versioned activity is registered for the name)
Not found
To carry the explicit-vs-inherited distinction end-to-end, the SDK stamps a reserved tag on the scheduled activity event:
Tag key: microsoft.durabletask.activity.version-source
Values: "explicit" (caller supplied ActivityOptions.Version) or "inherited" (activity inherited from a non-empty orchestration instance version).
The SDK strips this reserved key from any user-supplied Tags to prevent spoofing.
The worker reads the tag from the inbound ActivityRequest.tags to decide between strict and inherited dispatch. If the tag is missing on a versioned request, the worker fails closed (treats it as explicit, no fallback). This means a sidecar that drops tags cannot silently degrade strict-explicit semantics to inherited-with-fallback — those calls return ActivityTaskNotFound instead.
Note: when both the orchestration is unversioned (no instance version) and the activity call is plain (no explicit version), the SDK stamps no tag at all, matching the pre-versioning behavior.
6. Work-item filters
When UseWorkItemFilters() is called, the filter generation is version-aware:
If all registrations for a logical name are explicitly versioned, the filter includes only those specific versions.
If any registration for a name is unversioned, the filter conservatively matches all versions for that name (the unversioned registration is a catch-all).
7. ContinueAsNewOptions.NewVersion
ContinueAsNewOptions.NewVersion (already present in the proto and SDK) is the migration mechanism. When an orchestration calls ContinueAsNew with NewVersion set, the restarted instance is routed to the new version's implementation. This is the safest migration point because the history is fully reset.
There is intentionally no dedicated context.MigrateToVersion(...) helper in v1. ContinueAsNew(input, new ContinueAsNewOptions { NewVersion = "2" }) is already discoverable, expressive, and minimal. A wrapper would add public API surface for marginal ergonomic benefit and can be added later if user feedback demands it.
When migrating, callers should be aware that:
Any outstanding sub-orchestrations or activities scheduled before the ContinueAsNew boundary continue to run under the old version's logic until they complete.
Preserved external events that arrive across the boundary will be delivered to the new-version instance — make sure handler signatures and event payload schemas remain compatible across versions.
Deterministic state derived from context.NewGuid() and context.CurrentUtcDateTime resets across the boundary, so any IDs/timestamps generated in v1 must be carried explicitly in input if v2 needs them.
Migrating an existing class to per-task versioning
Important
Adding [DurableTaskVersion] to an orchestrator class that already has in-flight, unversioned instances is a breaking change for those instances unless the migration is staged. Pre-versioning instances were scheduled with Version = "" (empty); after the annotation, the registry only knows (name, "v1") and the strict orchestrator dispatch refuses to fall back to an unversioned registration that no longer exists.
Recommended migration recipe for OrderWorkflow (already running in production, currently unversioned):
Step 1 — Add the new versioned class without removing the old one. Keep the existing unversioned OrderWorkflow class registered (via AddOrchestrator<OrderWorkflow>() or its source-generated equivalent) and add a new OrderWorkflowV2 class with [DurableTaskVersion("2")]. At this point the registry has both (OrderWorkflow, "") and (OrderWorkflow, "2"). In-flight unversioned instances continue to dispatch against the unversioned registration (exact match). New code starts v2 instances explicitly via ScheduleNewOrderWorkflow_2InstanceAsync(...).
Step 2 — Drain or ContinueAsNew the unversioned instances. Wait for outstanding unversioned instances to complete, or transition them to v2 at a safe point with context.ContinueAsNew(input, new ContinueAsNewOptions { NewVersion = "2" }).
Step 3 — Once no unversioned instances exist, you may remove the unversioned registration. From this point only versioned instances exist; the registry contains only versioned registrations and the strict-dispatch rule applies normally.
The same recipe applies to activities. The reverse migration (removing [DurableTaskVersion] from a class that has versioned in-flight instances) is not supported: those instances will fail with OrchestratorTaskNotFound because the registry no longer offers their version. Drain or ContinueAsNew to an unversioned registration first.
What this does NOT change
No protobuf changes required by this SDK feature — the orchestration-side fields (CompleteOrchestrationAction.newVersion, StartOrchestrationOptions.version) already exist. The ActivityRequest.tags field is added on the protobuf side to carry the version-source signal end-to-end (see microsoft/durabletask-protobuf#68).
No breaking changes to shipped APIs — existing unversioned orchestrators and activities, worker-level UseVersioning(), and all public APIs continue to work identically. The behavior change called out in the migration recipe above only affects code that opts into per-task versioning.
No change to the default activity-routing model — plain CallActivityAsync(...) without an explicit activity version still inherits the orchestration instance version.
No Azure Functions support for same-name multi-version in v1 — the source generator emits diagnostic DURABLE3004 if Azure Functions projects attempt to register multiple versions of the same orchestration or activity name (this would produce colliding function triggers).
No entity versioning — entities are out of scope for this proposal.
Tag rename note (pre-release only)
While this proposal was iterating, an earlier draft used a tag named microsoft.durabletask.activity.explicit-version with boolean values. The shipping name is microsoft.durabletask.activity.version-source with values "explicit" / "inherited". The SDK does not recognize the old name. Any pre-release deployments that produced histories carrying the old tag should drain or restart their instances on the new SDK before pointing them at the new contract — the new worker fails-closed on a missing version-source tag for a versioned activity request, so untagged in-flight versioned activities will return ActivityTaskNotFound rather than silently degrading.
Interaction with worker-level versioning
Important
Per-task [DurableTaskVersion] routing and worker-level UseVersioning() cannot be combined in the same worker. The worker constructor throws InvalidOperationException at startup if both are configured.
Both features consume the orchestration instance version field, but they target different problems:
Worker-level versioning filters and stamps work items at the deployment boundary (MatchStrategy = Strict | CurrentOrOlder).
Per-task versioning routes work items inside the worker to the correct implementation.
When both are configured, the worker-level version check would run before per-task dispatch. Any orchestration whose instance version doesn't match the worker's version would be rejected as VersionMismatch, masking the per-task routing. To prevent that silent failure mode, the worker fails fast at construction. The check lives in WorkerVersioningPolicy.EnsureNotCombined so every worker subclass (gRPC and any future transport) gets the same guarantee.
Pick one of the two strategies for a given worker; do not enable both.
Azure Functions caveats
Per-task versioning has specific limitations in Azure Functions that require extension-side changes.
Same-name multi-version tasks are not supported in v1
In Azure Functions (.NET isolated worker), each orchestrator and activity class generates a function trigger via DurableMetadataTransformer, which derives the function name directly from TaskName.ToString(). If two classes share the same [DurableTask("OrderWorkflow")] name, both generate triggers with the same function name, causing a collision.
The source generator prevents this at compile time with diagnostic DURABLE3004.
Why this is an Azure Functions-specific constraint
In standalone workers, the runtime dispatches by (name, version) internally — there are no external trigger bindings that need unique names. In Azure Functions, the function name is derived from the orchestration name in DurableMetadataTransformer, creating a 1:1 constraint that doesn't exist outside Functions.
Required changes in azure-functions-durable-extension
DurableMetadataTransformer (src/Worker.Extensions.DurableTask/Execution/DurableMetadataTransformer.cs) — currently iterates registry.GetOrchestrators() and creates function metadata using kvp.Key.ToString() as the function name. Would need to extract both name and version from registry keys and emit version-qualified function names while keeping the durable logical name stable (deriving the trigger identity directly from (name, version) would force users to rename existing functions when adopting per-task versioning).
DurableFunctionExecutor.Orchestration / Activity — currently dispatches using context.FunctionDefinition.Name as a plain name lookup via factory.TryCreateOrchestrator(name, ...). Would need to dispatch against the (name, version) key produced by the metadata transformer.
These changes depend on the durabletask-dotnet SDK exposing version-aware registry iteration and factory lookup, which this proposal provides.
What does work in Azure Functions today
Single-version [DurableTaskVersion] orchestrators and activities work fine (only one implementation per name).
ContinueAsNewOptions.NewVersion can be used to stamp a new version on restart, though routing to a different implementation class requires the multi-version extension support described above.
Worker-level versioning via UseVersioning() continues to work as before.
New diagnostics
ID
Severity
Description
DURABLE3003
Error
Duplicate [DurableTaskVersion] for the same [DurableTask] name in standalone mode
DURABLE3004
Error
Same-name multi-version orchestrators or activities in Azure Functions mode (not supported in v1)
DURABLE3005
Error
[DurableTaskVersion] argument is whitespace-only. Provide a non-empty version string or omit the attribute.
Per-Task Versioning: Architecture Proposal
Summary
Add support for per-task version routing — the ability to register multiple implementations of the same logical orchestration or activity name (differentiated by version) in a single worker process. This complements the existing worker-level versioning (
UseVersioning()) which pins the entire worker to one version.Scope: Orchestrations and activities. Entity versioning is out of scope for this proposal.
Motivation
Today, orchestration versioning in durabletask-dotnet works at the worker level: you set
UseVersioning()with a single version string, and the worker only accepts work items matching that version. To upgrade orchestration logic, you deploy a new worker binary.This model works well for simple rolling deployments, but it doesn't address scenarios where:
OrderWorkflowis on v2 butPaymentWorkflowis still on v1Real-world example
A team has an eternal
MonitorWorkfloworchestration that runs 24/7. They need to change its logic. With worker-level versioning, they must coordinate a full worker swap. With per-orchestrator versioning, they deploy both v1 and v2 in the same worker, let existing instances drain on v1, and useContinueAsNew(new ContinueAsNewOptions { NewVersion = "2" })to migrate individual instances at a safe point.Why this complements worker-level versioning
Worker-level versioning is still the right tool when versioning is primarily a deployment concern. It lets one deployment stamp and accept one version, which keeps straightforward rolling upgrades simple when a deployment runs exactly one implementation of each task.
Per-task versioning solves a different problem: task routing inside a worker. It lets one worker host v1 and v2 of
OrderWorkflow, keepPaymentWorkflowon v1, and route activity implementations alongside the currently executing orchestration version. It also allows an orchestration to override that default inheritance when a specific activity version must be called explicitly. That avoids introducing extra worker pools just to express which implementation should run for a particular logical task.These strategies are complementary, not replacements for one another:
Proposed API
1.
[DurableTaskVersion]attributeA new attribute to declare the version of a class-based orchestrator or activity:
Multiple classes can share the same
[DurableTask]name as long as each has a unique[DurableTaskVersion]value.Recommended version string format
The Durable Task Scheduler (DTS) backend validates the orchestration version field as
Major[.Minor[.Patch]]semver (for example1,1.2, or1.2.3). For maximum portability and forward compatibility, use semver-compatible version strings such as"1","1.0", or"2.0.1".The SDK itself does not validate the contents of the
[DurableTaskVersion]attribute orTaskVersionstrings — values are passed through to the backend as-is. Non-semver values (for example,"v1"or"alpha") may work against backends that do not enforce a format but will be rejected by DTS at scheduling time. This is intentional: the SDK stays backend-agnostic, but the recommendation above keeps you compatible with all supported backends.The SDK does reject the obviously-broken case:
[DurableTaskVersion(" ")](whitespace-only) is rejected at compile time by source generator diagnosticDURABLE3005, atTaskVersionconstruction time, and atAddOrchestrator/AddActivityregistration. Pass an unversioned task by omitting the attribute entirely, by passingnull/"", or by usingTaskVersion.Unversionedon the manual registration overloads.2. Source generator changes
When the generator detects multiple orchestrators or activities sharing the same task name with different versions, it produces version-qualified helper methods instead of the unqualified ones:
For orchestrator and sub-orchestrator helpers, a private
ApplyGeneratedVersion()helper ensures the correct version is set onStartOrchestrationOptions.VersionorSubOrchestrationOptions.Versionwithout requiring the caller to manage it manually.For activities, generated version-qualified helpers stamp
ActivityOptions.Versionwhen the caller has not already provided one. This makesCallProcessPayment_1Async(...)andCallProcessPayment_2Async(...)true explicit selectors instead of compile-time aliases only.If the caller passes a non-null
options.Version(orActivityOptions.Version) that disagrees with the version baked into the generator-emitted helper name — including the explicit-unversioned case (TaskVersion.Unversioned) — the helper throwsInvalidOperationExceptionat runtime rather than silently routing to a different version than the method name advertises. To call a different version, use the unqualifiedScheduleNewOrchestrationInstanceAsync/CallSubOrchestratorAsync/CallActivityAsyncoverload.For single-version orchestrators and activities, the unqualified helper is generated as before for backward compatibility. For single-version activities with
[DurableTaskVersion], the unqualified helper also stamps the declared activity version.AddAllGeneratedTasks()registers all versioned implementations automatically.The generated method name suffix is constructed by escaping every non-alphanumeric character (including
_) as_xHHHH_, which makes it injective — distinct version strings always produce distinct method names.3. Manual registration API
For non-class-based scenarios, overloads of
AddOrchestratorandAddActivityacceptTaskVersion:Whitespace-only version strings are rejected by the
TaskVersionconstructor withArgumentException. PassTaskVersion.Unversioned(ordefault(TaskVersion), or use the existing name-only overloads) to register an unversioned default.4. Explicit activity version selection
A new
ActivityOptions : TaskOptionspublic type exposes aTaskVersion? Versionproperty:ActivityOptions.Versionis tri-state:null— inherit the orchestration instance version (default).TaskVersion.Unversioned(i.e.,default(TaskVersion)) — explicitly request the unversioned activity.new TaskVersion("X")— explicitly request version X.Any non-null
Versionis treated as an explicit selection; the value of the inner string only determines which registration the worker looks up.TaskVersion's constructor normalizesnullandstring.Emptyto theUnversionedform, and the struct's equality / hash treat the two as identical, soTaskVersion.Unversioned == new TaskVersion("")and using either as a dictionary key works.5. Worker dispatch
The worker's internal factory resolves implementations using
(TaskName, TaskVersion).Orchestrator dispatch
When a work item arrives for an orchestration instance:
(name, version), use it.The fallback is intentionally narrow. If the registry has any versioned registration for the name (e.g.,
v1,v2), a request forv3returns "not found" instead of silently routing to a coexisting unversioned default the caller did not ask for.Activity dispatch
Activity routing depends on whether the version was explicit (caller-supplied via
ActivityOptions.Version) or inherited (the default). The two paths differ on whether the unversioned-fallback step is allowed:(name, version)(name, version)To carry the explicit-vs-inherited distinction end-to-end, the SDK stamps a reserved tag on the scheduled activity event:
microsoft.durabletask.activity.version-source"explicit"(caller suppliedActivityOptions.Version) or"inherited"(activity inherited from a non-empty orchestration instance version).The SDK strips this reserved key from any user-supplied
Tagsto prevent spoofing.The worker reads the tag from the inbound
ActivityRequest.tagsto decide between strict and inherited dispatch. If the tag is missing on a versioned request, the worker fails closed (treats it asexplicit, no fallback). This means a sidecar that drops tags cannot silently degrade strict-explicit semantics to inherited-with-fallback — those calls returnActivityTaskNotFoundinstead.Note: when both the orchestration is unversioned (no instance version) and the activity call is plain (no explicit version), the SDK stamps no tag at all, matching the pre-versioning behavior.
6. Work-item filters
When
UseWorkItemFilters()is called, the filter generation is version-aware:7.
ContinueAsNewOptions.NewVersionContinueAsNewOptions.NewVersion(already present in the proto and SDK) is the migration mechanism. When an orchestration callsContinueAsNewwithNewVersionset, the restarted instance is routed to the new version's implementation. This is the safest migration point because the history is fully reset.There is intentionally no dedicated
context.MigrateToVersion(...)helper in v1.ContinueAsNew(input, new ContinueAsNewOptions { NewVersion = "2" })is already discoverable, expressive, and minimal. A wrapper would add public API surface for marginal ergonomic benefit and can be added later if user feedback demands it.When migrating, callers should be aware that:
ContinueAsNewboundary continue to run under the old version's logic until they complete.context.NewGuid()andcontext.CurrentUtcDateTimeresets across the boundary, so any IDs/timestamps generated in v1 must be carried explicitly ininputif v2 needs them.Migrating an existing class to per-task versioning
Important
Adding
[DurableTaskVersion]to an orchestrator class that already has in-flight, unversioned instances is a breaking change for those instances unless the migration is staged. Pre-versioning instances were scheduled withVersion = ""(empty); after the annotation, the registry only knows(name, "v1")and the strict orchestrator dispatch refuses to fall back to an unversioned registration that no longer exists.Recommended migration recipe for
OrderWorkflow(already running in production, currently unversioned):Step 1 — Add the new versioned class without removing the old one. Keep the existing unversioned
OrderWorkflowclass registered (viaAddOrchestrator<OrderWorkflow>()or its source-generated equivalent) and add a newOrderWorkflowV2class with[DurableTaskVersion("2")]. At this point the registry has both(OrderWorkflow, "")and(OrderWorkflow, "2"). In-flight unversioned instances continue to dispatch against the unversioned registration (exact match). New code starts v2 instances explicitly viaScheduleNewOrderWorkflow_2InstanceAsync(...).Step 2 — Drain or
ContinueAsNewthe unversioned instances. Wait for outstanding unversioned instances to complete, or transition them to v2 at a safe point withcontext.ContinueAsNew(input, new ContinueAsNewOptions { NewVersion = "2" }).Step 3 — Once no unversioned instances exist, you may remove the unversioned registration. From this point only versioned instances exist; the registry contains only versioned registrations and the strict-dispatch rule applies normally.
The same recipe applies to activities. The reverse migration (removing
[DurableTaskVersion]from a class that has versioned in-flight instances) is not supported: those instances will fail withOrchestratorTaskNotFoundbecause the registry no longer offers their version. Drain orContinueAsNewto an unversioned registration first.What this does NOT change
CompleteOrchestrationAction.newVersion,StartOrchestrationOptions.version) already exist. TheActivityRequest.tagsfield is added on the protobuf side to carry the version-source signal end-to-end (see microsoft/durabletask-protobuf#68).UseVersioning(), and all public APIs continue to work identically. The behavior change called out in the migration recipe above only affects code that opts into per-task versioning.CallActivityAsync(...)without an explicit activity version still inherits the orchestration instance version.DURABLE3004if Azure Functions projects attempt to register multiple versions of the same orchestration or activity name (this would produce colliding function triggers).Tag rename note (pre-release only)
While this proposal was iterating, an earlier draft used a tag named
microsoft.durabletask.activity.explicit-versionwith boolean values. The shipping name ismicrosoft.durabletask.activity.version-sourcewith values"explicit"/"inherited". The SDK does not recognize the old name. Any pre-release deployments that produced histories carrying the old tag should drain or restart their instances on the new SDK before pointing them at the new contract — the new worker fails-closed on a missingversion-sourcetag for a versioned activity request, so untagged in-flight versioned activities will returnActivityTaskNotFoundrather than silently degrading.Interaction with worker-level versioning
Important
Per-task
[DurableTaskVersion]routing and worker-levelUseVersioning()cannot be combined in the same worker. The worker constructor throwsInvalidOperationExceptionat startup if both are configured.Both features consume the orchestration instance version field, but they target different problems:
MatchStrategy = Strict | CurrentOrOlder).When both are configured, the worker-level version check would run before per-task dispatch. Any orchestration whose instance version doesn't match the worker's version would be rejected as
VersionMismatch, masking the per-task routing. To prevent that silent failure mode, the worker fails fast at construction. The check lives inWorkerVersioningPolicy.EnsureNotCombinedso every worker subclass (gRPC and any future transport) gets the same guarantee.Pick one of the two strategies for a given worker; do not enable both.
Azure Functions caveats
Per-task versioning has specific limitations in Azure Functions that require extension-side changes.
Same-name multi-version tasks are not supported in v1
In Azure Functions (.NET isolated worker), each orchestrator and activity class generates a function trigger via
DurableMetadataTransformer, which derives the function name directly fromTaskName.ToString(). If two classes share the same[DurableTask("OrderWorkflow")]name, both generate triggers with the same function name, causing a collision.The source generator prevents this at compile time with diagnostic
DURABLE3004.Why this is an Azure Functions-specific constraint
In standalone workers, the runtime dispatches by
(name, version)internally — there are no external trigger bindings that need unique names. In Azure Functions, the function name is derived from the orchestration name inDurableMetadataTransformer, creating a 1:1 constraint that doesn't exist outside Functions.Required changes in
azure-functions-durable-extensionSupporting multi-version tasks in Azure Functions would require changes in the Durable Functions extension:
DurableMetadataTransformer(src/Worker.Extensions.DurableTask/Execution/DurableMetadataTransformer.cs) — currently iteratesregistry.GetOrchestrators()and creates function metadata usingkvp.Key.ToString()as the function name. Would need to extract both name and version from registry keys and emit version-qualified function names while keeping the durable logical name stable (deriving the trigger identity directly from(name, version)would force users to rename existing functions when adopting per-task versioning).DurableFunctionExecutor.Orchestration/Activity— currently dispatches usingcontext.FunctionDefinition.Nameas a plain name lookup viafactory.TryCreateOrchestrator(name, ...). Would need to dispatch against the(name, version)key produced by the metadata transformer.These changes depend on the durabletask-dotnet SDK exposing version-aware registry iteration and factory lookup, which this proposal provides.
What does work in Azure Functions today
[DurableTaskVersion]orchestrators and activities work fine (only one implementation per name).ContinueAsNewOptions.NewVersioncan be used to stamp a new version on restart, though routing to a different implementation class requires the multi-version extension support described above.UseVersioning()continues to work as before.New diagnostics
DURABLE3003[DurableTaskVersion]for the same[DurableTask]name in standalone modeDURABLE3004DURABLE3005[DurableTaskVersion]argument is whitespace-only. Provide a non-empty version string or omit the attribute.