-
Notifications
You must be signed in to change notification settings - Fork 55
Per-Task Versioning: Architecture Proposal #692
Description
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:
- 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.,
OrderWorkflowis on v2 butPaymentWorkflowis 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")]
public class OrderWorkflowV1 : TaskOrchestrator<int, string> { ... }
[DurableTask("OrderWorkflow")]
[DurableTaskVersion("2")]
public class OrderWorkflowV2 : TaskOrchestrator<int, string> { ... }
// Activities — same pattern
[DurableTask("ProcessPayment")]
[DurableTaskVersion("1")]
public class ProcessPaymentV1 : TaskActivity<PaymentRequest, PaymentResult> { ... }
[DurableTask("ProcessPayment")]
[DurableTaskVersion("2")]
public class ProcessPaymentV2 : TaskActivity<PaymentRequest, PaymentResult> { ... }Multiple classes can share the same [DurableTask] name as long as each has a unique [DurableTaskVersion] value.
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-stamped
client.ScheduleNewOrderWorkflow_1InstanceAsync(input);
client.ScheduleNewOrderWorkflow_2InstanceAsync(input);
// Sub-orchestrator equivalents
context.CallOrderWorkflow_1Async(input);
context.CallOrderWorkflow_2Async(input);
// Generated activity helpers
context.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.
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.
3. Manual registration API
For non-class-based scenarios, overloads of AddOrchestrator and AddActivity accept TaskVersion:
registry.AddOrchestrator("OrderWorkflow", new TaskVersion("1"), () => new OrderWorkflowV1());
registry.AddOrchestrator("OrderWorkflow", new TaskVersion("2"), () => new OrderWorkflowV2());
registry.AddActivity("ProcessPayment", new TaskVersion("1"), () => new ProcessPaymentV1());
registry.AddActivity("ProcessPayment", new TaskVersion("2"), () => new ProcessPaymentV2());4. Explicit activity version selection
Add a new ActivityOptions : TaskOptions public type with a TaskVersion? Version property:
await context.CallActivityAsync<PaymentResult>(
"ProcessPayment",
request,
new ActivityOptions
{
Version = "2",
});This override is additive. If the caller does not provide an explicit activity version, activity scheduling continues to use the parent orchestration instance version by default.
5. Worker dispatch
The worker's internal factory resolves implementations using (TaskName, TaskVersion) for both orchestrators and activities.
For activities, the requested version is resolved in this order:
- Explicit activity version from
ActivityOptions.Version - Inherited orchestration version from the current orchestration instance
The final activity lookup then uses the existing dispatch model:
- Exact match — if a registration exists for the requested
(name, version), use it - Unversioned fallback — if no exact match and the name has an unversioned registration, use that as the default/catch-all
- Not found — return failure
This ensures backward compatibility: unversioned orchestrators and activities continue to work as before and serve as defaults for any version that lacks an explicit registration.
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.
What this does NOT change
- No protobuf changes — all required fields (
CompleteOrchestrationAction.newVersion,StartOrchestrationOptions.version) already exist - No breaking changes — existing unversioned orchestrators and activities, worker-level
UseVersioning(), and all public APIs continue to work identically - 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
DURABLE3004if 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
Interaction with worker-level versioning
Per-task [DurableTaskVersion] routing and worker-level UseVersioning() both use the orchestration instance version field, but they solve different problems:
- Worker-level versioning is deployment-scoped filtering and stamping
- Per-task versioning is implementation routing inside a worker
They should not be combined in the same worker, as worker-level version checks execute before per-task dispatch and could reject legitimate versioned work items.
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
Supporting 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
- Generate version-qualified function names (e.g.,
OrderWorkflow_v1,OrderWorkflow_v2)
-
DurableFunctionExecutor.Orchestration(src/Worker.Extensions.DurableTask/Execution/DurableFunctionExecutor.Orchestration.cs) — currently dispatches usingcontext.FunctionDefinition.Nameas a plain name lookup viafactory.TryCreateOrchestrator(name, ...). Would need to:- Parse the version back out of the qualified function name
- Pass the version to the factory's version-aware overload
-
DurableFunctionExecutor.Activity(src/Worker.Extensions.DurableTask/Execution/DurableFunctionExecutor.Activity.cs) — same pattern as orchestrations, same changes needed.
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.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- 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) |
Open questions
- Should we add a
context.MigrateToVersion("2")convenience method that wrapsContinueAsNewwith justNewVersion? - What version string format should we recommend? (The DTS backend currently validates
Major[.Minor[.Patch]]semver format for the version field)