Skip to content

Per-Task Versioning: Architecture Proposal #692

@torosent

Description

@torosent

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:

  1. Multiple versions must be active simultaneously — e.g., existing instances continue on v1 while new instances start on v2
  2. Version routing should be per-orchestration — e.g., OrderWorkflow is on v2 but PaymentWorkflow is still on v1
  3. 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
  4. 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:

  1. Explicit activity version from ActivityOptions.Version
  2. Inherited orchestration version from the current orchestration instance

The final activity lookup then uses the existing dispatch model:

  1. Exact match — if a registration exists for the requested (name, version), use it
  2. Unversioned fallback — if no exact match and the name has an unversioned registration, use that as the default/catch-all
  3. 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 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

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:

  1. 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
    • Generate version-qualified function names (e.g., OrderWorkflow_v1, OrderWorkflow_v2)
  2. DurableFunctionExecutor.Orchestration (src/Worker.Extensions.DurableTask/Execution/DurableFunctionExecutor.Orchestration.cs) — currently dispatches using context.FunctionDefinition.Name as a plain name lookup via factory.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
  3. 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.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)

Open questions

  1. Should we add a context.MigrateToVersion("2") convenience method that wraps ContinueAsNew with just NewVersion?
  2. What version string format should we recommend? (The DTS backend currently validates Major[.Minor[.Patch]] semver format for the version field)

Metadata

Metadata

Assignees

No one assigned

    Labels

    EnhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions