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.

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-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.

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:

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());

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 v2
await context.CallActivityAsync<PaymentResult>(
    "ProcessPayment",
    request,
    new ActivityOptions { Version = "2" });

// Explicit unversioned (call the unversioned ProcessPayment from a versioned orchestration)
await context.CallActivityAsync<PaymentResult>(
    "ProcessPayment",
    request,
    new ActivityOptions { Version = TaskVersion.Unversioned });

// Inherit (the default — same as plain TaskOptions)
await context.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:

  1. Exact match — if a registration exists for the requested (name, version), use it.
  2. 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.
  3. 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):

  1. 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(...).

  2. 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" }).

  3. 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

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

  2. 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.

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