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
108 changes: 108 additions & 0 deletions samples/UnversionedFallbackSample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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
{
UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch,
});
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 UnversionedFallback 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);
78 changes: 78 additions & 0 deletions samples/UnversionedFallbackSample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 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 `DurableTaskWorkerOptions.VersioningOptions.UnversionedFallback = WhenNoExactMatch`.
- `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.
- Unversioned fallback is opt-in. Without `WhenNoExactMatch`, a mixed unversioned plus versioned registration remains a closed set and unknown versions fail rather than falling back.
- Use this mode only when the unversioned implementation is compatible with the versions it may receive. Replaying existing histories against a different implementation can cause non-determinism or deserialization failures.
- `UseWorkItemFilters()` composes with this mode by allowing unmatched versions for logical names that have an unversioned catch-all registration.

## 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>
2 changes: 1 addition & 1 deletion src/Abstractions/TaskOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public TaskOptions(TaskOptions options)
/// 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.
/// versioned registrations at all, unless unversioned fallback is explicitly enabled on the worker.
/// </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,20 @@ public IHostedService Build(IServiceProvider serviceProvider)
Verify.NotNull(this.buildTarget, error);

DurableTaskRegistry registry = serviceProvider.GetOptions<DurableTaskRegistry>(this.Name);
DurableTaskWorkerOptions workerOptions = serviceProvider.GetOptions<DurableTaskWorkerOptions>(this.Name);
if (workerOptions.Versioning?.UnversionedFallback
== DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch)
{
ILoggerFactory? loggerFactory = serviceProvider.GetService<ILoggerFactory>();
if (loggerFactory is not null)
{
Logs.CreateWorkerLogger(loggerFactory).UnversionedFallbackEnabled(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));
}
}
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)

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,7 @@
DefaultVersion = versionOptions.DefaultVersion,
MatchStrategy = versionOptions.MatchStrategy,
FailureStrategy = versionOptions.FailureStrategy,
UnversionedFallback = versionOptions.UnversionedFallback,
};
});
return builder;
Expand Down
Loading
Loading