Skip to content

Per-Task Versioning: Architecture Proposal #692

@torosent

Description

@torosent

Per-Task Versioning: Architecture Proposal

Summary

Extend the existing worker-level versioning (UseVersioning()) so that a single worker can host multiple implementations of the same orchestration or activity name, differentiated by version, and so that callers can schedule an activity or sub-orchestration at an explicit version.

Scope: Orchestrations and activities. Entity versioning is out of scope.

Motivation

The fundamental gap today is that a worker cannot host two implementations of the same orchestrator or activity name at the same time. The registry keys by TaskName, and the worker's VersioningOptions.Version is a single string. As a result:

  1. Existing instances on v1 and new instances on v2 cannot coexist in one deployment — you have to spin up a separate worker pool per version just to express that two implementations of OrderWorkflow are both currently valid.
  2. There's no way for a caller to explicitly schedule a specific activity version (the version is inherited from the orchestration instance, with no override).

The existing VersioningOptions.MatchStrategy = CurrentOrOlder already establishes the model — workers accept multiple instance versions. What's missing is the registry, dispatch, and scheduling plumbing to make that model useful when the implementations actually differ.

Real-world example

A team has an eternal MonitorWorkflow orchestration that runs 24/7. They need to change its logic. Today they have to coordinate a full worker swap. With multi-version registration, they deploy both v1 and v2 in the same worker (under UseVersioning(version: "2", matchStrategy: CurrentOrOlder)), let existing instances drain on v1, and use ContinueAsNew(new ContinueAsNewOptions { NewVersion = "2" }) to migrate individual instances at a safe point.

Design principles

  • One feature, one entry point. Versioning is configured via UseVersioning(). No separate UsePerTaskVersioning() toggle.
  • One dispatch rule. Exact match on (name, version). If no exact match exists and the registry has any versioned registration for the name, return OrchestratorTaskNotFound / ActivityTaskNotFound rather than silently falling back. The unversioned-fallback path exists only for backward compatibility with names that have no versioned registrations at all.

Proposed API

1. Version property on [DurableTask]

Declare a class-based orchestrator or activity version by setting Version on the existing [DurableTask] attribute:

[DurableTask("OrderWorkflow", Version = "1")]
public class OrderWorkflowV1 : TaskOrchestrator<int, string> { ... }

[DurableTask("OrderWorkflow", Version = "2")]
public class OrderWorkflowV2 : TaskOrchestrator<int, string> { ... }

Multiple classes may share the same [DurableTask] name as long as each has a unique Version. Whitespace-only Version values are rejected at compile time by source generator diagnostic DURABLE3005 and at TaskVersion construction time. Duplicate (name, version) registrations in standalone mode are rejected by DURABLE3003.

Recommended version string format

The Durable Task Scheduler (DTS) backend validates the orchestration version field as Major[.Minor[.Patch]] semver. Use semver-compatible strings ("1", "1.0", "2.0.1"). The SDK does not validate version contents — values pass through to the backend as-is — but non-semver values will be rejected by DTS at scheduling time.

2. Source generator: helpers named after the class

When the generator emits helpers for [DurableTask("OrderWorkflow", Version = "2")] class OrderWorkflowV2, the helper is derived from the class name rather than the durable task name in multi-class-per-name registrations:

// Single class with name "OrderWorkflow" (versioned or not):
client.ScheduleNewOrderWorkflowInstanceAsync(input);

// Two classes sharing name "OrderWorkflow" with distinct Version values:
//   class OrderWorkflowV1 : [DurableTask("OrderWorkflow", Version = "1")]
//   class OrderWorkflowV2 : [DurableTask("OrderWorkflow", Version = "2")]
// Helpers are class-derived:
client.ScheduleNewOrderWorkflowV1InstanceAsync(input);
client.ScheduleNewOrderWorkflowV2InstanceAsync(input);
context.CallOrderWorkflowV2Async(input);

// Activity:
context.CallProcessPaymentV2Async(request);

Single-class registrations keep their existing helper names (derived from the durable task name). Multi-class-per-name registrations switch to class-derived helper names so each is unique. Class names are guaranteed unique within a namespace by the C# compiler.

The helper bakes the declared Version into the generated registration call, so callers don't have to pass TaskOptions { Version = ... } manually.

AddAllGeneratedTasks() registers all classes (versioned and unversioned) automatically.

3. Manual registration API

For non-class-based scenarios, overloads of AddOrchestrator, AddActivity, AddOrchestratorFunc<TIn, TOut>, and AddActivityFunc<TIn, TOut> accept TaskVersion:

registry.AddOrchestrator("OrderWorkflow", new TaskVersion("1"), () => new OrderWorkflowV1());
registry.AddOrchestrator("OrderWorkflow", new TaskVersion("2"), () => new OrderWorkflowV2());
registry.AddActivity("ProcessPayment", new TaskVersion("2"), () => new ProcessPaymentV2());

// Lambda / function-based:
registry.AddOrchestratorFunc<int, string>(
    "OrderWorkflow",
    new TaskVersion("2"),
    async (context, input) => { /* v2 logic */ });

Pass TaskVersion.Unversioned (or use the existing name-only overload) to register an unversioned default. The TaskVersion constructor normalizes null and string.Empty to Unversioned, so default(TaskVersion) == new TaskVersion("") == TaskVersion.Unversioned and the struct is safe to use as a dictionary key.

4. Explicit version on scheduling

TaskOptions.Version (on the base TaskOptions record) lets a caller stamp an explicit version on an outbound activity or sub-orchestration call. StartOrchestrationOptions.Version is the client-side equivalent for new orchestrations.

// Activity at explicit v2:
await context.CallActivityAsync<PaymentResult>(
    "ProcessPayment",
    request,
    new TaskOptions { Version = "2" });

// Sub-orchestration at explicit v1 from a v2 parent:
await context.CallSubOrchestratorAsync<string>(
    "AuditWorkflow",
    auditInput,
    new SubOrchestrationOptions { Version = "1" });

// Default (no override) — inherit the orchestration instance version:
await context.CallActivityAsync<PaymentResult>("ProcessPayment", request);

Version is TaskVersion?:

  • null — inherit the orchestration instance version (default).
  • new TaskVersion("X") — explicitly request version X.
  • TaskVersion.Unversioned — explicitly request the unversioned implementation (only meaningful when calling from a versioned orchestration into a name that still has an unversioned registration).

Version on the base TaskOptions keeps the activity and sub-orchestration paths symmetric. Entity calls go through a different API and are unaffected.

5. Worker dispatch — one rule, two task types

When a work item arrives, the worker resolves the implementation by:

  1. Exact match on (name, version) — use it.
  2. No exact match, and the registry has no versioned registration for this name — fall back to the unversioned registration. This preserves the upgrade path for code that has not yet adopted versioning.
  3. No exact match, and at least one versioned registration exists for this name — return OrchestratorTaskNotFound / ActivityTaskNotFound.

The same rule applies to orchestrators and activities, and to sub-orchestrations: a sub-orchestration scheduled by a versioned parent without an explicit Version inherits the parent's instance version (innerContext.Version).

6. Work-item filters

UseWorkItemFilters() emits the concrete distinct version set actually registered for each name (treating null/unversioned as ""). Under MatchStrategy.Strict, the worker overrides this with the single configured worker version. No wildcard "match any version" set is emitted, so the backend never streams work items the worker would reject after the fact.

7. ContinueAsNewOptions.NewVersion

ContinueAsNew(input, new ContinueAsNewOptions { NewVersion = "2" }) is the supported migration mechanism: the restarted instance dispatches to the new version's implementation. History is fully reset, so this is the safe boundary for changing version.

When migrating:

  • Sub-orchestrations and 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 — event handler signatures and payload schemas must remain compatible across versions.
  • Deterministic state derived from context.NewGuid() and context.CurrentUtcDateTime resets across the boundary.

Relationship to UseVersioning() (worker-level versioning)

This proposal extends the existing UseVersioning(). It does not introduce a parallel feature.

UseVersioning(Version = "X", MatchStrategy = ...) already defines:

  • The worker's own versionVersion is stamped on newly started orchestrations (DefaultVersion) and used by the filter that decides which inbound work items to accept.
  • The match strategyStrict (only this exact version) or CurrentOrOlder (this version and below). None disables version filtering altogether.
  • The failure strategyReject or Fail on a version mismatch.

What this proposal changes:

  • The registry under UseVersioning() now accepts multiple (name, version) registrations. The same worker can register OrderWorkflow@v1 and OrderWorkflow@v2 simultaneously.
  • Under MatchStrategy = CurrentOrOlder, the worker accepts work items whose instance version is <= its own version. Dispatch picks the registered implementation matching the instance version exactly (rule from §5), or fails if none exists.
  • [DurableTask(Version = ...)] is recognized by the registry as a class-based equivalent of the (name, version) manual overload.

Migrating an existing class to versioned

Important

Adding Version to a [DurableTask] attribute on a class with in-flight unversioned instances is a breaking change for those instances unless the migration is staged. Pre-versioning instances were scheduled with empty version; after the annotation the registry no longer has an unversioned entry, and dispatch returns OrchestratorTaskNotFound.

Recommended migration for OrderWorkflow (already running in production, currently unversioned):

  1. Add the new versioned class without removing the old one. Keep the existing unversioned OrderWorkflow class registered and add a new OrderWorkflowV2 class with [DurableTask("OrderWorkflow", Version = "2")]. The registry now has both (OrderWorkflow, "") and (OrderWorkflow, "2"). In-flight unversioned instances continue to dispatch against the unversioned registration. New code starts v2 instances via ScheduleNewOrderWorkflowV2InstanceAsync(...).

  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. Once no unversioned instances exist, remove the unversioned registration. From this point only versioned instances exist, and the exact-match rule applies normally.

The reverse migration (removing Version from a class that has versioned in-flight instances) is not supported — those instances will fail with OrchestratorTaskNotFound. Drain or ContinueAsNew to an unversioned registration first.

What this does NOT change

  • No protobuf changes are required by the SDK feature. Orchestration version, StartOrchestrationOptions.version, CompleteOrchestrationAction.newVersion, and activity scheduling all already exist on the wire.
  • No breaking changes to shipped APIs. Existing unversioned orchestrators and activities, the existing UseVersioning(), and all public APIs continue to work identically. Adopting [DurableTask(Version = ...)] on a previously unversioned class follows the migration recipe above.
  • No change to the default activity-routing model. Plain CallActivityAsync(...) without an explicit TaskOptions.Version inherits the orchestration instance version.
  • No Azure Functions support for same-name multi-version. The source generator emits 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.

Azure Functions caveats

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.

What works in Azure Functions today

  • Single-version [DurableTask("X", Version = "v1")] orchestrators and activities (one implementation per name).
  • ContinueAsNewOptions.NewVersion to stamp a new version on restart, though routing to a different implementation class requires the Azure Functions extension changes described below.
  • UseVersioning() worker-level versioning continues to work as before.

Required follow-up in azure-functions-durable-extension (future, optional)

Supporting multi-version tasks in Azure Functions would require changes in the Durable Functions extension:

  1. DurableMetadataTransformer — currently iterates registry entries and creates function metadata keyed by the durable task 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.

  2. DurableFunctionExecutor.Orchestration / Activity — currently dispatches using context.FunctionDefinition.Name as a plain name lookup. Would need to dispatch against the (name, version) key produced by the metadata transformer.

Implementing this is purely additive on the SDK side — a future version-aware enumeration method (e.g., EnumerateRegistrations() returning (TaskName, TaskVersion, factory) triples) and any necessary factory changes can be added without breaking existing extension consumers.

Forward compatibility with the currently shipped extension

This proposal changes the internal DurableTaskRegistry.Orchestrators and Activities properties from IDictionary<TaskName, Func<...>> to IDictionary<TaskVersionKey, Func<...>>. The currently shipped extension (Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.4.0) reflects on those internal properties and casts the result to IEnumerable<KeyValuePair<TaskName, Func<...>>> (see DurableTaskRegistryExtensions.cs:33,51 in the extension repo). Generic invariance would make that cast fail with InvalidCastException once a Functions app picks up the new SDK.

To preserve compatibility without coordinating a release:

  1. Internal storage keeps the TaskVersionKey keying needed by the new feature. The version-keyed dictionaries are renamed (OrchestratorsByVersion, ActivitiesByVersion).

  2. Compatibility projections are kept under the original property names. The Orchestrators and Activities properties remain internal and return IEnumerable<KeyValuePair<TaskName, Func<...>>> projections of the version-keyed dictionaries — one KVP per registration with the version dimension collapsed. The shipped extension's reflection cast continues to succeed.

  3. New public API. DurableTaskRegistry.GetOrchestrators(), GetActivities(), and GetEntities() are added as the supported way to enumerate registrations. The extension can migrate to these methods as a follow-up PR with no urgency.

  4. Entities is unaffected — that property is already TaskName-keyed and is not changed by this proposal.

  5. DURABLE3004 still enforces single-version registrations in Functions mode, so the projection's name-only semantics are loss-free for any registry the extension will see at runtime.

The SDK can ship independently. The extension repo will follow up with a PR that replaces its reflection with the new public methods.

New diagnostics

ID Severity Description
DURABLE3003 Error Duplicate [DurableTask] name + Version combination in standalone mode.
DURABLE3004 Error Same-name multi-version orchestrators or activities in Azure Functions mode (not supported).
DURABLE3005 Error [DurableTask] Version argument is whitespace-only. Provide a non-empty version string or omit the Version argument.

Metadata

Metadata

Assignees

No one assigned

    Labels

    EnhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions