Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Microsoft.DurableTask.sln
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActivityVersioningSample",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityWithVersionedOrchestrationSample", "samples\EntityWithVersionedOrchestrationSample\EntityWithVersionedOrchestrationSample.csproj", "{8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnversionedFallbackSample", "samples\UnversionedFallbackSample\UnversionedFallbackSample.csproj", "{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -761,6 +763,18 @@ Global
{8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x64.Build.0 = Release|Any CPU
{8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x86.ActiveCfg = Release|Any CPU
{8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x86.Build.0 = Release|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Debug|x64.ActiveCfg = Debug|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Debug|x64.Build.0 = Debug|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Debug|x86.ActiveCfg = Debug|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Debug|x86.Build.0 = Debug|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Release|Any CPU.Build.0 = Release|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Release|x64.ActiveCfg = Release|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Release|x64.Build.0 = Release|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Release|x86.ActiveCfg = Release|Any CPU
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -824,6 +838,7 @@ Global
{1E30F09F-1ADA-4375-81CC-F0FBC74D5621} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
{1C0E65CE-1B36-48BB-B688-FA3AAFDE8A25} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ Durable Task Scheduler provides durable execution in Azure. Durable execution is

This SDK can also be used with the Durable Task Scheduler directly, without any Durable Functions dependency. For getting started, you can find documentation and samples [here](https://learn.microsoft.com/en-us/azure/azure-functions/durable/what-is-durable-task).

For runnable DTS emulator examples that demonstrate versioning, see the [WorkerVersioningSample](samples/WorkerVersioningSample/README.md) (deployment-based versioning), the [EternalOrchestrationVersionMigrationSample](samples/EternalOrchestrationVersionMigrationSample/README.md) (multi-version routing with `[DurableTask(Version = "...")]`), the [ActivityVersioningSample](samples/ActivityVersioningSample/README.md) (activity versioning with inherited defaults and explicit override support), and the [EntityWithVersionedOrchestrationSample](samples/EntityWithVersionedOrchestrationSample/README.md) (a single instance migrating v1→v2 via `ContinueAsNew(NewVersion)` while preserving entity-held state).
For runnable DTS emulator examples that demonstrate versioning, see the [WorkerVersioningSample](samples/WorkerVersioningSample/README.md) (deployment-based versioning), the [EternalOrchestrationVersionMigrationSample](samples/EternalOrchestrationVersionMigrationSample/README.md) (multi-version routing with `[DurableTask(Version = "...")]`), the [ActivityVersioningSample](samples/ActivityVersioningSample/README.md) (activity versioning with inherited defaults and explicit override support), the [EntityWithVersionedOrchestrationSample](samples/EntityWithVersionedOrchestrationSample/README.md) (a single instance migrating v1→v2 via `ContinueAsNew(NewVersion)` while preserving entity-held state), and the [UnversionedFallbackSample](samples/UnversionedFallbackSample/README.md) (an unversioned catch-all for unmatched explicit versions).

## Obtaining the Protobuf definitions

Expand Down
112 changes: 112 additions & 0 deletions samples/UnversionedFallbackSample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// This sample demonstrates opt-in unversioned fallback for per-task versioning.
// A worker can register one explicit legacy implementation for a known version
// and an unversioned implementation as the catch-all for versions that do not
// have an explicit [DurableTask(Version = "...")] registration.

using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

string connectionString = builder.Configuration.GetValue<string>("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? throw new InvalidOperationException(
"Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING. " +
"For the local emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None");

builder.Services.AddDurableTaskWorker(wb =>
{
wb.AddTasks(tasks => tasks.AddAllGeneratedTasks());
wb.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions
{
// Activity fallback is the safer place to start: activities are stateless and do not replay
// history. Enable orchestrator fallback (commented below) only when the unversioned
// orchestrator is replay-compatible with every version it may receive.
ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll,
OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll,
});
wb.UseWorkItemFilters();
wb.UseDurableTaskScheduler(connectionString);
});

builder.Services.AddDurableTaskClient(cb => cb.UseDurableTaskScheduler(connectionString));

IHost host = builder.Build();
await host.StartAsync();

await using DurableTaskClient client = host.Services.GetRequiredService<DurableTaskClient>();

Console.WriteLine("=== Unversioned fallback for versioned task dispatch ===");
Console.WriteLine();

SupportRequest request = new("Contoso", "BGP session down");

Console.WriteLine("Scheduling SupportWorkflow version 1.4.0 ...");
string legacyId = await client.ScheduleNewOrchestrationInstanceAsync(
nameof(SupportWorkflow),
request,
new StartOrchestrationOptions
{
Version = new TaskVersion("1.4.0"),
});
OrchestrationMetadata legacy = await client.WaitForInstanceCompletionAsync(legacyId, getInputsAndOutputs: true);
Console.WriteLine($" Result: {legacy.ReadOutputAs<string>()}");
Console.WriteLine();

Console.WriteLine("Scheduling SupportWorkflow version 1.0 ...");
string fallbackId = await client.ScheduleNewOrchestrationInstanceAsync(
nameof(SupportWorkflow),
request,
new StartOrchestrationOptions
{
Version = new TaskVersion("1.0"),
});
OrchestrationMetadata fallback = await client.WaitForInstanceCompletionAsync(fallbackId, getInputsAndOutputs: true);
Console.WriteLine($" Result: {fallback.ReadOutputAs<string>()}");
Console.WriteLine();

Console.WriteLine("Done! Version 1.4.0 used the explicit legacy class; version 1.0 used the unversioned fallback.");

await host.StopAsync();

/// <summary>
/// The current implementation. With OrchestratorUnversionedFallback enabled, this unversioned registration
/// handles every requested SupportWorkflow version that does not have an exact explicit registration.
/// </summary>
[DurableTask(nameof(SupportWorkflow))]
public sealed class SupportWorkflow : TaskOrchestrator<SupportRequest, string>
{
/// <inheritdoc />
public override Task<string> RunAsync(TaskOrchestrationContext context, SupportRequest input)
{
return Task.FromResult(
$"Current SupportWorkflow handled version '{context.Version}' for {input.Customer}: {input.Issue}");
}
}

/// <summary>
/// A pinned legacy implementation for version 1.4.0.
/// </summary>
[DurableTask(nameof(SupportWorkflow), Version = "1.4.0")]
public sealed class SupportWorkflowLegacyV140 : TaskOrchestrator<SupportRequest, string>
{
/// <inheritdoc />
public override Task<string> RunAsync(TaskOrchestrationContext context, SupportRequest input)
{
return Task.FromResult(
$"Legacy SupportWorkflow 1.4.0 handled version '{context.Version}' for {input.Customer}: {input.Issue}");
}
}

/// <summary>
/// Request input for the support workflow.
/// </summary>
public sealed record SupportRequest(string Customer, string Issue);
82 changes: 82 additions & 0 deletions samples/UnversionedFallbackSample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Unversioned Fallback Sample

This sample demonstrates opt-in unversioned fallback for per-task versioning. It shows how one explicit versioned class can coexist with an unversioned catch-all implementation for versions that do not have their own `[DurableTask(Version = "...")]` registration.

## What it shows

- `SupportWorkflowLegacyV140` is registered as `[DurableTask(nameof(SupportWorkflow), Version = "1.4.0")]`.
- `SupportWorkflow` is registered without a version and acts as the current catch-all implementation.
- The worker enables both `OrchestratorUnversionedFallback = CatchAll` and `ActivityUnversionedFallback = CatchAll`. The orchestrator flag is what the demo exercises; the activity flag is set to illustrate that the two sides are configured independently.
- `UseWorkItemFilters()` is enabled, so the generated filter must allow unmatched versions to reach the worker.
- A version `1.4.0` request dispatches to the explicit legacy class.
- A version `1.0` request has no exact registration, so it dispatches to the unversioned fallback class.

## Prerequisites

- .NET 10.0 SDK
- [Docker](https://www.docker.com/get-started)

## Running the Sample

### 1. Start the DTS emulator

```bash
docker run --name durabletask-emulator -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```

The emulator exposes the gRPC sidecar on port 8080 and the local dashboard on port 8082. After running the sample below, you can open the dashboard at <http://localhost:8082> to inspect the orchestrations and their versions.

### 2. Set the connection string

```bash
export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
```

PowerShell:

```powershell
$env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
```

### 3. Run the sample

```bash
dotnet run
```

Expected output:

```text
=== Unversioned fallback for versioned task dispatch ===

Scheduling SupportWorkflow version 1.4.0 ...
Result: Legacy SupportWorkflow 1.4.0 handled version '1.4.0' for Contoso: BGP session down

Scheduling SupportWorkflow version 1.0 ...
Result: Current SupportWorkflow handled version '1.0' for Contoso: BGP session down

Done! Version 1.4.0 used the explicit legacy class; version 1.0 used the unversioned fallback.
```

### 4. Clean up

```bash
docker rm -f durabletask-emulator
```

## Key takeaways

- Exact version matches always win. A `1.4.0` request dispatches to the `1.4.0` class, not the unversioned class.
- Three modes are available per side via `UnversionedFallbackMode`:
- `Implicit` (default) — the unversioned registration serves versioned requests only when the name has no versioned siblings. Matches behavior before per-task versioning shipped.
- `CatchAll` — opt-in catch-all for unmatched versioned requests on mixed names. This sample uses it.
- `StrictExactOnly` — every versioned request requires an exact `(name, version)` registration. Use when bogus versions from upstream clients should fail loudly.
- Orchestrator and activity fallback are configured independently. `OrchestratorUnversionedFallback` carries replay risk (orchestrators rehydrate state from history on every replay); `ActivityUnversionedFallback` is safer because activities are stateless. Start with activity-only `CatchAll` if you are unsure.
- Use orchestrator `CatchAll` only when the unversioned implementation is replay-compatible with the versions it may receive. Replaying existing histories against a different implementation can cause non-determinism or deserialization failures.
- `UseWorkItemFilters()` composes with these modes: it widens to a wildcard when the worker can actually serve unmatched versioned requests (under `Implicit` for unversioned-only names, under `CatchAll` whenever an unversioned registration exists). Under `StrictExactOnly` the filter emits the concrete version set so the backend does not deliver work items the worker would reject.

## See also

- [EternalOrchestrationVersionMigrationSample](../EternalOrchestrationVersionMigrationSample/README.md) — multi-version orchestration dispatch and `ContinueAsNew(NewVersion = "...")` migration.
- [ActivityVersioningSample](../ActivityVersioningSample/README.md) — activity versioning with inherited defaults and explicit overrides.
- [WorkerVersioningSample](../WorkerVersioningSample/README.md) — worker-level deployment versioning via `UseVersioning()`.
25 changes: 25 additions & 0 deletions samples/UnversionedFallbackSample/UnversionedFallbackSample.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>

<ItemGroup>
<!-- Using p2p references so we can show latest changes in samples. -->
<ProjectReference Include="$(SrcRoot)Client/AzureManaged/Client.AzureManaged.csproj" />
<ProjectReference Include="$(SrcRoot)Worker/AzureManaged/Worker.AzureManaged.csproj" />
<ProjectReference Include="$(SrcRoot)Analyzers/Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

<!-- Reference the source generator -->
<ProjectReference Include="$(SrcRoot)Generators/Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
13 changes: 10 additions & 3 deletions src/Abstractions/TaskOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,16 @@ public TaskOptions(TaskOptions options)
/// </para>
/// <para>
/// When non-<c>null</c> (including <see cref="TaskVersion.Unversioned"/>), the task is scheduled with the
/// specified version explicitly. The worker dispatches to the registered <c>(name, version)</c> exactly;
/// when no exact match exists, it falls back to an unversioned registration only when the name has no
/// versioned registrations at all.
/// specified version explicitly, overriding any inherited version.
/// </para>
/// <para>
/// The receiving worker is responsible for resolving the scheduled version to a registered implementation
/// according to its own versioning configuration. That resolution is not visible to the scheduling client.
/// </para>
/// <para>
/// When deploying versioned workloads, treat each worker's versioning configuration as a deployment-time
/// policy: the same version value may dispatch to different registrations across deployments. Use the
/// worker's startup and per-dispatch diagnostic logs to verify behavior across environments.
/// </para>
/// </remarks>
public TaskVersion? Version { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.DurableTask.Worker.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Microsoft.DurableTask.Worker;

Expand Down Expand Up @@ -54,10 +55,25 @@ public IHostedService Build(IServiceProvider serviceProvider)
Verify.NotNull(this.buildTarget, error);

DurableTaskRegistry registry = serviceProvider.GetOptions<DurableTaskRegistry>(this.Name);
DurableTaskWorkerOptions workerOptions = serviceProvider.GetOptions<DurableTaskWorkerOptions>(this.Name);
ILoggerFactory? loggerFactory = serviceProvider.GetService<ILoggerFactory>();
if (loggerFactory is not null && workerOptions.Versioning is { } versioning)
{
ILogger workerLogger = Logs.CreateWorkerLogger(loggerFactory);
if (versioning.OrchestratorUnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll)
{
workerLogger.OrchestratorUnversionedFallbackEnabled(this.Name);
}

if (versioning.ActivityUnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll)
{
workerLogger.ActivityUnversionedFallbackEnabled(this.Name);
}
}

// Note: Modifying any logic in this section could introduce breaking changes.
// Do not alter the input parameter.
return (IHostedService)ActivatorUtilities.CreateInstance(
serviceProvider, this.buildTarget, this.Name, registry.BuildFactory());
serviceProvider, this.buildTarget, this.Name, registry.BuildFactory(workerOptions, loggerFactory));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
where TTarget : DurableTaskWorker
where TOptions : DurableTaskWorkerOptions
{
builder.UseBuildTarget(typeof(TTarget));

Check warning on line 82 in src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / smoke-tests

Prefer the generic overload 'Microsoft.DurableTask.Worker.DurableTaskWorkerBuilderExtensions.UseBuildTarget<TTarget>(Microsoft.DurableTask.Worker.IDurableTaskWorkerBuilder)' instead of 'Microsoft.DurableTask.Worker.DurableTaskWorkerBuilderExtensions.UseBuildTarget(Microsoft.DurableTask.Worker.IDurableTaskWorkerBuilder, System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 82 in src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Prefer the generic overload 'Microsoft.DurableTask.Worker.DurableTaskWorkerBuilderExtensions.UseBuildTarget<TTarget>(Microsoft.DurableTask.Worker.IDurableTaskWorkerBuilder)' instead of 'Microsoft.DurableTask.Worker.DurableTaskWorkerBuilderExtensions.UseBuildTarget(Microsoft.DurableTask.Worker.IDurableTaskWorkerBuilder, System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)
builder.Services.AddOptions<TOptions>(builder.Name)
.PostConfigure<IOptionsMonitor<DurableTaskWorkerOptions>>((options, baseOptions) =>
{
Expand All @@ -106,6 +106,8 @@
DefaultVersion = versionOptions.DefaultVersion,
MatchStrategy = versionOptions.MatchStrategy,
FailureStrategy = versionOptions.FailureStrategy,
OrchestratorUnversionedFallback = versionOptions.OrchestratorUnversionedFallback,
ActivityUnversionedFallback = versionOptions.ActivityUnversionedFallback,
};
});
return builder;
Expand Down
Loading
Loading