From f222622cef0d61c76a4c274af1483b7b565640da Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Wed, 20 May 2026 11:29:41 -0700 Subject: [PATCH 01/15] Add unversioned fallback for versioned task dispatch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.DurableTask.sln | 15 +++ README.md | 2 +- samples/UnversionedFallbackSample/Program.cs | 108 +++++++++++++++++ samples/UnversionedFallbackSample/README.md | 78 +++++++++++++ .../UnversionedFallbackSample.csproj | 25 ++++ src/Abstractions/TaskOptions.cs | 2 +- .../DefaultDurableTaskWorkerBuilder.cs | 13 ++- .../DurableTaskWorkerBuilderExtensions.cs | 1 + src/Worker/Core/DurableTaskFactory.cs | 31 ++--- .../Core/DurableTaskRegistryExtensions.cs | 17 ++- src/Worker/Core/DurableTaskWorkerOptions.cs | 38 ++++++ .../Core/DurableTaskWorkerWorkItemFilters.cs | 26 +++-- src/Worker/Core/Logs.cs | 6 + .../DefaultDurableTaskWorkerBuilderTests.cs | 67 +++++++++++ .../UseWorkItemFiltersTests.cs | 107 +++++++++++++++++ ...rableTaskFactoryActivityVersioningTests.cs | 28 +++++ .../DurableTaskFactoryVersioningTests.cs | 29 +++++ .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 109 ++++++++++++++++++ 18 files changed, 672 insertions(+), 30 deletions(-) create mode 100644 samples/UnversionedFallbackSample/Program.cs create mode 100644 samples/UnversionedFallbackSample/README.md create mode 100644 samples/UnversionedFallbackSample/UnversionedFallbackSample.csproj diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 0b05c4186..9a00fb4b5 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -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 @@ -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 @@ -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} diff --git a/README.md b/README.md index 84686d372..8090c1816 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/samples/UnversionedFallbackSample/Program.cs b/samples/UnversionedFallbackSample/Program.cs new file mode 100644 index 000000000..e15680af1 --- /dev/null +++ b/samples/UnversionedFallbackSample/Program.cs @@ -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("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(); + +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()}"); +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()}"); +Console.WriteLine(); + +Console.WriteLine("Done! Version 1.4.0 used the explicit legacy class; version 1.0 used the unversioned fallback."); + +await host.StopAsync(); + +/// +/// The current implementation. With UnversionedFallback enabled, this unversioned registration handles every +/// requested SupportWorkflow version that does not have an exact explicit registration. +/// +[DurableTask(nameof(SupportWorkflow))] +public sealed class SupportWorkflow : TaskOrchestrator +{ + /// + public override Task RunAsync(TaskOrchestrationContext context, SupportRequest input) + { + return Task.FromResult( + $"Current SupportWorkflow handled version '{context.Version}' for {input.Customer}: {input.Issue}"); + } +} + +/// +/// A pinned legacy implementation for version 1.4.0. +/// +[DurableTask(nameof(SupportWorkflow), Version = "1.4.0")] +public sealed class SupportWorkflowLegacyV140 : TaskOrchestrator +{ + /// + public override Task RunAsync(TaskOrchestrationContext context, SupportRequest input) + { + return Task.FromResult( + $"Legacy SupportWorkflow 1.4.0 handled version '{context.Version}' for {input.Customer}: {input.Issue}"); + } +} + +/// +/// Request input for the support workflow. +/// +public sealed record SupportRequest(string Customer, string Issue); diff --git a/samples/UnversionedFallbackSample/README.md b/samples/UnversionedFallbackSample/README.md new file mode 100644 index 000000000..e8f918536 --- /dev/null +++ b/samples/UnversionedFallbackSample/README.md @@ -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 -e ASPNETCORE_URLS=http://+:8080 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 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()`. diff --git a/samples/UnversionedFallbackSample/UnversionedFallbackSample.csproj b/samples/UnversionedFallbackSample/UnversionedFallbackSample.csproj new file mode 100644 index 000000000..2f40b4719 --- /dev/null +++ b/samples/UnversionedFallbackSample/UnversionedFallbackSample.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + + + + + + + + + + + + + + + + + diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index a2f400f23..78f63a14e 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -64,7 +64,7 @@ public TaskOptions(TaskOptions options) /// When non-null (including ), the task is scheduled with the /// specified version explicitly. The worker dispatches to the registered (name, version) 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. /// /// public TaskVersion? Version { get; init; } diff --git a/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs b/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs index 5e9669923..28a024af6 100644 --- a/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs +++ b/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs @@ -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; @@ -54,10 +55,20 @@ public IHostedService Build(IServiceProvider serviceProvider) Verify.NotNull(this.buildTarget, error); DurableTaskRegistry registry = serviceProvider.GetOptions(this.Name); + DurableTaskWorkerOptions workerOptions = serviceProvider.GetOptions(this.Name); + if (workerOptions.Versioning?.UnversionedFallback + == DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch) + { + ILoggerFactory? loggerFactory = serviceProvider.GetService(); + 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)); } } diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index a92740788..3ca86560f 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -106,6 +106,7 @@ public static IDurableTaskWorkerBuilder UseVersioning(this IDurableTaskWorkerBui DefaultVersion = versionOptions.DefaultVersion, MatchStrategy = versionOptions.MatchStrategy, FailureStrategy = versionOptions.FailureStrategy, + UnversionedFallback = versionOptions.UnversionedFallback, }; }); return builder; diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index fcc97800f..8ba2533dd 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -16,6 +16,7 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedTaskFactory readonly IDictionary> entities; readonly HashSet versionedOrchestratorNames; readonly HashSet versionedActivityNames; + readonly bool useUnversionedFallback; /// /// Initializes a new instance of the class. @@ -23,19 +24,21 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedTaskFactory /// The activity factories. /// The orchestrator factories. /// The entity factories. + /// The unversioned fallback mode. internal DurableTaskFactory( IDictionary> activities, IDictionary> orchestrators, - IDictionary> entities) + IDictionary> entities, + DurableTaskWorkerOptions.UnversionedFallbackMode unversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Never) { this.activities = Check.NotNull(activities); this.orchestrators = Check.NotNull(orchestrators); this.entities = Check.NotNull(entities); + this.useUnversionedFallback = unversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch; - // Snapshot the set of logical names that have at least one versioned registration. Used to gate the - // unversioned-fallback path: when a logical name has any versioned registration, we refuse to fall - // back to its unversioned registration for an unmatched versioned request — that would silently - // route the call to a different implementation than the caller asked for. + // Snapshot the set of logical names that have at least one versioned registration. By default, this gates + // unversioned fallback so a mixed versioned/unversioned name remains a closed set. Workers can opt in to + // allowing the unversioned registration to handle unmatched versions. this.versionedOrchestratorNames = new HashSet( this.orchestrators.Keys .Where(k => !string.IsNullOrWhiteSpace(k.Version)) @@ -63,12 +66,11 @@ public bool TryCreateActivity( return true; } - // Unversioned registrations remain the compatibility fallback for a versioned request, but ONLY when - // no versioned registration exists for the same logical name. This mirrors the orchestrator rule: - // once a name has any versioned registration, an unmatched versioned request returns "not found" - // rather than silently routing to a catch-all the caller did not ask for. + // Unversioned registrations remain the compatibility fallback for a versioned request when no versioned + // registration exists for the same logical name. Workers can also opt in to treating the unversioned + // registration as a catch-all for unmatched versions. if (!string.IsNullOrWhiteSpace(version.Version) - && !this.versionedActivityNames.Contains(name.Name) + && (this.useUnversionedFallback || !this.versionedActivityNames.Contains(name.Name)) && this.activities.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { activity = factory.Invoke(serviceProvider); @@ -99,12 +101,11 @@ public bool TryCreateOrchestrator( return true; } - // Unversioned registrations remain the compatibility fallback for a versioned request, but ONLY when - // no versioned registration exists for the same logical name. If any versioned registration is present - // (e.g., v1 and v2 are registered, request asks for v3), we refuse to silently route the call to a - // catch-all registration the caller did not ask for. + // Unversioned registrations remain the compatibility fallback for a versioned request when no versioned + // registration exists for the same logical name. Workers can also opt in to treating the unversioned + // registration as a catch-all for unmatched versions. if (!string.IsNullOrWhiteSpace(version.Version) - && !this.versionedOrchestratorNames.Contains(name.Name) + && (this.useUnversionedFallback || !this.versionedOrchestratorNames.Contains(name.Name)) && this.orchestrators.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { orchestrator = factory.Invoke(serviceProvider); diff --git a/src/Worker/Core/DurableTaskRegistryExtensions.cs b/src/Worker/Core/DurableTaskRegistryExtensions.cs index 5d1853945..799e0af4c 100644 --- a/src/Worker/Core/DurableTaskRegistryExtensions.cs +++ b/src/Worker/Core/DurableTaskRegistryExtensions.cs @@ -14,8 +14,23 @@ static class DurableTaskRegistryExtensions /// The registry to build. /// The built factory. public static IDurableTaskFactory BuildFactory(this DurableTaskRegistry registry) + => registry.BuildFactory(null); + + /// + /// Builds a into a . + /// + /// The registry to build. + /// The worker options to use when building the factory. + /// The built factory. + public static IDurableTaskFactory BuildFactory(this DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { Check.NotNull(registry); - return new DurableTaskFactory(registry.ActivitiesByVersion, registry.OrchestratorsByVersion, registry.Entities); + DurableTaskWorkerOptions.UnversionedFallbackMode unversionedFallback = + workerOptions?.Versioning?.UnversionedFallback ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Never; + return new DurableTaskFactory( + registry.ActivitiesByVersion, + registry.OrchestratorsByVersion, + registry.Entities, + unversionedFallback); } } diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 3aa4eea20..724d14efa 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -49,6 +49,23 @@ public enum VersionFailureStrategy Fail = 1, } + /// + /// Defines whether unversioned task registrations can be used when no exact version match exists. + /// + public enum UnversionedFallbackMode + { + /// + /// Only use an unversioned task registration for unversioned requests, or for versioned requests when + /// the task name has no versioned registrations. + /// + Never = 0, + + /// + /// Use an unversioned task registration when no exact versioned registration exists for the requested task. + /// + WhenNoExactMatch = 1, + } + /// /// Gets or sets the data converter. Default value is . /// @@ -243,6 +260,27 @@ public class VersioningOptions /// If the version matching strategy is set to , this value has no effect. /// public VersionFailureStrategy FailureStrategy { get; set; } = VersionFailureStrategy.Reject; + + /// + /// Gets or sets whether unversioned task registrations can be used when no exact version match exists. + /// + /// + /// + /// The default value, , preserves the closed-set behavior for + /// mixed unversioned and versioned registrations. In this mode, a request for an unknown version does not + /// fall back to the unversioned registration once any versioned registration exists for the same task name. + /// + /// + /// When set to , an exact versioned registration still + /// wins, but unmatched versioned requests can use the unversioned registration as a catch-all implementation. + /// + /// + /// WARNING: Only enable this mode when the unversioned implementation is compatible with every version it may + /// receive. Replaying an existing orchestration or activity history against a different implementation can + /// cause non-determinism or deserialization failures. + /// + /// + public UnversionedFallbackMode UnversionedFallback { get; set; } = UnversionedFallbackMode.Never; } /// diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index ccedc3b19..c86d67b06 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -43,21 +43,23 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict ? [workerOptions.Versioning.Version ?? string.Empty] : null; + bool useUnversionedFallback = + workerOptions?.Versioning?.UnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch; // Orchestration filters group registrations by logical name and emit the concrete distinct // version set actually registered (treating null/unversioned as ""). Strict mode overrides - // this with the single configured worker version. For unversioned-only names (no versioned - // registration exists for the name), we emit an empty version list — the filter wildcard — - // so the backend can deliver versioned work items that the factory will then resolve via - // the documented unversioned fallback in DurableTaskFactory.TryCreateOrchestrator. When a - // name has at least one versioned registration, the factory refuses unversioned-fallback, - // so emitting the concrete version set prevents the backend from streaming work items the - // worker would then reject after the fact. + // this with the single configured worker version. When the factory can resolve unknown + // versions via an unversioned registration (unversioned-only names, or mixed names with + // opt-in unversioned fallback), we emit an empty version list — the filter wildcard — so the + // backend can deliver versioned work items the factory can handle. Otherwise, emitting the + // concrete version set prevents the backend from streaming work items the worker would then + // reject after the fact. List orchestrationFilters = registry.OrchestratorsByVersion .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { - IReadOnlyList versions = strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version)); + IReadOnlyList versions = + strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useUnversionedFallback); return new OrchestrationFilter { @@ -71,7 +73,8 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable .GroupBy(activity => activity.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { - IReadOnlyList versions = strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version)); + IReadOnlyList versions = + strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useUnversionedFallback); return new ActivityFilter { @@ -92,7 +95,7 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable }).ToList(), }; - static IReadOnlyList GetFilterVersions(IEnumerable versions) + static IReadOnlyList GetFilterVersions(IEnumerable versions, bool useUnversionedFallback) { // Normalize null to "" so an unversioned registration appears consistently. string[] normalized = versions @@ -105,7 +108,8 @@ static IReadOnlyList GetFilterVersions(IEnumerable versions) // versioned work items that the factory will resolve via unversioned fallback. Without // this, callers asking for a specific version would be filtered out at the backend even // though the worker can handle them. - if (normalized.Length == 1 && normalized[0].Length == 0) + if ((normalized.Length == 1 && normalized[0].Length == 0) + || (useUnversionedFallback && normalized.Contains(string.Empty, StringComparer.OrdinalIgnoreCase))) { return []; } diff --git a/src/Worker/Core/Logs.cs b/src/Worker/Core/Logs.cs index 81a65e253..6a38ea398 100644 --- a/src/Worker/Core/Logs.cs +++ b/src/Worker/Core/Logs.cs @@ -35,6 +35,12 @@ static partial class Logs [LoggerMessage(EventId = 605, Level = LogLevel.Information, Message = "'{Name}' activity of orchestration ID '{InstanceId}' failed.")] public static partial void ActivityFailed(this ILogger logger, Exception ex, string instanceId, string name); + [LoggerMessage( + EventId = 606, + Level = LogLevel.Warning, + Message = "Unversioned fallback mode is enabled for Durable Task worker '{workerName}'. Unmatched versioned orchestrations and activities may run on unversioned registrations; ensure those implementations are replay-compatible with every version they may receive. Replaying existing histories against a different implementation can cause non-determinism or deserialization failures.")] + public static partial void UnversionedFallbackEnabled(this ILogger logger, string workerName); + /// /// Creates a logger named "Microsoft.DurableTask.Worker" with an optional subcategory. /// diff --git a/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs b/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs index e510da344..97b527988 100644 --- a/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs @@ -5,6 +5,7 @@ using Microsoft.DurableTask.Worker.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.DurableTask.Worker.Tests; @@ -85,6 +86,36 @@ public void Build_Target_Built() target.Options.DataConverter.Should().BeSameAs(converter); } + [Fact] + public void Build_WithUnversionedFallback_LogsWarning() + { + // Arrange + CapturingLoggerFactory loggerFactory = new(); + ServiceCollection services = new(); + services.AddOptions(); + services.AddSingleton(loggerFactory); + DefaultDurableTaskWorkerBuilder builder = new("test", services) + { + BuildTarget = typeof(GoodBuildTarget), + }; + builder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + }); + + // Act + builder.Build(services.BuildServiceProvider()); + + // Assert + loggerFactory.Logs.Should().Contain(log => + log.Level == LogLevel.Warning + && log.Message.Contains("unversioned", StringComparison.OrdinalIgnoreCase) + && log.Message.Contains("fallback", StringComparison.OrdinalIgnoreCase) + && log.Message.Contains("replay", StringComparison.OrdinalIgnoreCase) + && log.Message.Contains("non-determinism", StringComparison.OrdinalIgnoreCase) + && log.Message.Contains("deserialization", StringComparison.OrdinalIgnoreCase)); + } + class BadBuildTarget : BackgroundService { protected override Task ExecuteAsync(CancellationToken stoppingToken) @@ -130,4 +161,40 @@ class CustomDataConverter : DataConverter class GoodBuildTargetOptions : DurableTaskWorkerOptions { } + + sealed class CapturingLoggerFactory : ILoggerFactory + { + public List<(LogLevel Level, string Message)> Logs { get; } = []; + + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this.Logs); + + public void Dispose() + { + } + } + + sealed class CapturingLogger(List<(LogLevel Level, string Message)> logs) : ILogger + { + readonly List<(LogLevel Level, string Message)> logs = logs; + + public IDisposable? BeginScope(TState state) + where TState : notnull + => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + this.logs.Add((logLevel, formatter(state, exception))); + } + } } diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 6ad2eac85..a9eb07371 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -365,6 +365,40 @@ public void WorkItemFilters_UnversionedAndVersionedOrchestrators_EmitConcreteVer actual.Orchestrations[0].Versions.Should().BeEquivalentTo([string.Empty, "v2"]); } + [Fact] + public void WorkItemFilters_UnversionedFallbackWithMixedOrchestrators_EmitsWildcardVersionList() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEmpty(); + } + [Fact] public void WorkItemFilters_VersionedActivities_GroupVersionsByLogicalName() { @@ -421,6 +455,79 @@ public void WorkItemFilters_UnversionedAndVersionedActivities_EmitConcreteVersio actual.Activities[0].Versions.Should().BeEquivalentTo([string.Empty, "v2"]); } + [Fact] + public void WorkItemFilters_UnversionedFallbackWithMixedActivities_EmitsWildcardVersionList() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEmpty(); + } + + [Fact] + public void WorkItemFilters_UnversionedFallbackWithVersioningStrict_UsesConfiguredWorkerVersion() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1.0", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Versions.Should().BeEquivalentTo(["1.0"]); + } + [Fact] public void WorkItemFilters_DefaultEmptyRegistry_ProducesEmptyFilters() { diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs index dbef09470..ad6baa8b4 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -130,6 +130,34 @@ public void TryCreateActivity_WithMixedRegistrations_DoesNotFallBackToUnversione activity.Should().BeNull(); } + [Fact] + public void TryCreateActivity_WithMixedRegistrationsAndUnversionedFallback_UsesUnversionedRegistrationForUnknownVersion() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v2"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + [DurableTask("InvoiceActivity", Version = "v1")] sealed class InvoiceActivityV1 : TaskActivity { diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index 5244607e0..35150fa80 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -112,6 +112,35 @@ public void TryCreateOrchestrator_WithMixedRegistrations_DoesNotFallBackForUnkno orchestrator.Should().BeNull(); } + [Fact] + public void TryCreateOrchestrator_WithMixedRegistrationsAndUnversionedFallback_UsesUnversionedRegistrationForUnknownVersion() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v3"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + [Fact] public void TryCreateOrchestrator_WithOnlyUnversionedRegistration_FallsBackForVersionedRequest() { diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index 4c78aac00..8c527c2ea 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -28,6 +28,9 @@ public class GrpcDurableTaskWorkerTests static readonly MethodInfo ProcessorConnectAsyncMethod = typeof(GrpcDurableTaskWorker) .GetNestedType("Processor", BindingFlags.NonPublic)! .GetMethod("ConnectAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + static readonly MethodInfo ProcessorRunOrchestratorAsyncMethod = typeof(GrpcDurableTaskWorker) + .GetNestedType("Processor", BindingFlags.NonPublic)! + .GetMethod("OnRunOrchestratorAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; static readonly MethodInfo TryRecreateChannelAsyncMethod = typeof(GrpcDurableTaskWorker) .GetMethod("TryRecreateChannelAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -514,6 +517,71 @@ public void Constructor_StrictWorkerVersioningWithoutRegistryContents_DoesNotThr act.Should().NotThrow(); } + [Fact] + public async Task OnRunOrchestratorAsync_StrictVersioningWithUnversionedFallback_RejectsMismatchBeforeFactoryDispatch() + { + // Arrange + const string orchestrationName = "StrictFallbackWorkflow"; + string completionToken = Guid.NewGuid().ToString("N"); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1.0", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + FailureStrategy = DurableTaskWorkerOptions.VersionFailureStrategy.Reject, + UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + }, + Logging = { UseLegacyCategories = false }, + }; + Mock factoryMock = new(MockBehavior.Strict); + GrpcDurableTaskWorker worker = CreateWorker( + new GrpcDurableTaskWorkerOptions(), + workerOptions, + NullLoggerFactory.Instance, + factoryMock.Object); + Mock clientMock = new( + MockBehavior.Strict, + Mock.Of()); + TaskCompletionSource abandonRequest = new( + TaskCreationOptions.RunContinuationsAsynchronously); + clientMock + .Setup(c => c.AbandonTaskOrchestratorWorkItemAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((P.AbandonOrchestrationTaskRequest request, Metadata _, DateTime? _, CancellationToken _) => + { + abandonRequest.SetResult(request); + return CreateUnaryCall(Task.FromResult(new P.AbandonOrchestrationTaskResponse())); + }); + object processor = CreateProcessor(worker, clientMock.Object); + P.OrchestratorRequest request = CreateOrchestratorRequest( + orchestrationName, + instanceVersion: "2.0"); + + // Act + await InvokeProcessorRunOrchestratorAsync(processor, request, completionToken); + + // Assert + P.AbandonOrchestrationTaskRequest actualRequest = await abandonRequest.Task.WaitAsync(TimeSpan.FromSeconds(5)); + actualRequest.CompletionToken.Should().Be(completionToken); + factoryMock.Verify( + f => f.TryCreateOrchestrator( + It.IsAny(), + It.IsAny(), + out It.Ref.IsAny), + Times.Never); + clientMock.Verify( + c => c.CompleteOrchestratorTaskAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + [Fact] public void Constructor_NoWorkerVersioningWithoutRegistryContents_DoesNotThrow() { @@ -606,6 +674,17 @@ static object CreateProcessor(GrpcDurableTaskWorker worker, P.TaskHubSidecarServ return (AsyncServerStreamingCall)task.GetType().GetProperty("Result")!.GetValue(task)!; } + static async Task InvokeProcessorRunOrchestratorAsync( + object processor, + P.OrchestratorRequest request, + string completionToken) + { + Task task = (Task)ProcessorRunOrchestratorAsyncMethod.Invoke( + processor, + new object?[] { request, completionToken, CancellationToken.None })!; + await task; + } + static async Task InvokeProcessorExecuteAsync(object processor, CancellationToken cancellationToken) { Task task = (Task)ProcessorExecuteAsyncMethod.Invoke(processor, new object?[] { cancellationToken })!; @@ -613,6 +692,36 @@ static async Task InvokeProcessorExecuteAsync(object proces return (ProcessorExitReason)task.GetType().GetProperty("Result")!.GetValue(task)!; } + static P.OrchestratorRequest CreateOrchestratorRequest(string orchestrationName, string instanceVersion) + { + string instanceId = Guid.NewGuid().ToString("N"); + string executionId = Guid.NewGuid().ToString("N"); + return new P.OrchestratorRequest + { + InstanceId = instanceId, + ExecutionId = executionId, + NewEvents = + { + new P.HistoryEvent + { + EventId = -1, + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + ExecutionStarted = new P.ExecutionStartedEvent + { + Name = orchestrationName, + Version = instanceVersion, + Input = "\"input\"", + OrchestrationInstance = new P.OrchestrationInstance + { + InstanceId = instanceId, + ExecutionId = executionId, + }, + }, + }, + }, + }; + } + static void InvokeApplySuccessfulRecreate( GrpcDurableTaskWorker worker, object result, From 24e9af4035230f692f619ad69a7a60e553264338 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Thu, 21 May 2026 08:48:37 -0700 Subject: [PATCH 02/15] Stabilize PerItem_HeartbeatReset_KeepsTimerAlive flaky test The original test design had a ~100ms margin between the second item write and the silent-disconnect timer expiry (500ms timeout, 400ms inter-item delay, ~150ms thread scheduling). Under CI load, thread-pool scheduling jitter could push the second item write past the timer expiry, causing the consumer to return SilentDisconnect instead of GracefulDrain. Scale the timings up so both the with-reset and without-reset margins are ~500ms instead of ~100ms, and synchronize the test on the second item actually being dequeued before completing the channel. This removes the secondary race between writing the second item and the consumer dequeuing it. The test still verifies the same behavior: that ArmSilentDisconnectTimer is called per item so the original timer does not fire while items are still arriving. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Grpc.Tests/WorkItemStreamConsumerTests.cs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs b/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs index 2464c0c7e..67fb2b21e 100644 --- a/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs +++ b/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs @@ -142,9 +142,18 @@ public async Task PerItem_HeartbeatReset_KeepsTimerAlive() // Feed one item, wait long enough that the original timer would have expired, then complete. // Synchronize on the first item actually being processed so the second delay is measured from // the consumer's timer reset instead of from the test thread's write timing. + // + // Timings are sized to leave ~500ms of slack on both sides of the assertion so the test is + // robust to thread-pool scheduling jitter on loaded CI runners: + // - first delay (1000ms) + second delay (1500ms) = 2500ms total before 2nd item is written. + // - Without the per-item timer reset, the 2000ms original timer would have fired at 2000ms, + // leaving a ~500ms margin before the 2nd item is written (proves the test exercises the reset). + // - With the per-item reset, the new timer fires at first-delay + jitter + 2000ms, + // leaving ~500ms margin between the 2nd item write and the new timer expiry. Channel channel = Channel.CreateUnbounded(); - TimeSpan timeout = TimeSpan.FromMilliseconds(500); + TimeSpan timeout = TimeSpan.FromMilliseconds(2000); TaskCompletionSource firstItemProcessed = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource secondItemProcessed = new(TaskCreationOptions.RunContinuationsAsynchronously); int itemCount = 0; Task consumeTask = WorkItemStreamConsumer.ConsumeAsync( @@ -152,21 +161,31 @@ public async Task PerItem_HeartbeatReset_KeepsTimerAlive() silentDisconnectTimeout: timeout, onItem: _ => { - if (Interlocked.Increment(ref itemCount) == 1) + int seen = Interlocked.Increment(ref itemCount); + if (seen == 1) { firstItemProcessed.TrySetResult(); } + else if (seen == 2) + { + secondItemProcessed.TrySetResult(); + } }, onFirstMessage: null, cancellation: CancellationToken.None); - await Task.Delay(TimeSpan.FromMilliseconds(150)); + await Task.Delay(TimeSpan.FromMilliseconds(1000)); await channel.Writer.WriteAsync(new P.WorkItem { HealthPing = new P.HealthPing() }); - await firstItemProcessed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await firstItemProcessed.Task.WaitAsync(TimeSpan.FromSeconds(10)); // Without the per-item reset, the original timer would fire before this second item arrives. - await Task.Delay(TimeSpan.FromMilliseconds(400)); + await Task.Delay(TimeSpan.FromMilliseconds(1500)); await channel.Writer.WriteAsync(new P.WorkItem { HealthPing = new P.HealthPing() }); + + // Wait for the consumer to actually dequeue and process the 2nd item (which re-arms the timer) + // before completing the channel. Without this barrier, the test could observe a SilentDisconnect + // if the timer fires after the test writes the 2nd item but before the consumer dequeues it. + await secondItemProcessed.Task.WaitAsync(TimeSpan.FromSeconds(10)); channel.Writer.Complete(); WorkItemStreamResult result = await consumeTask; From ad2e986a8cbec539091eb7ece7f299d4decb7feb Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Thu, 21 May 2026 09:04:30 -0700 Subject: [PATCH 03/15] Clarify UnversionedFallbackMode doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous doc for 'Never' described when an unversioned registration IS used, but the enum name 'Never' reads as 'never used' — making the text confusing. Reframe both members in terms of fallback semantics (matching the enum name 'UnversionedFallbackMode') and make 'Never' explicitly say what's preserved (unversioned requests still served, fallback still applies when no versioned registration exists for the name). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskWorkerOptions.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 724d14efa..2d21b602b 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -50,18 +50,27 @@ public enum VersionFailureStrategy } /// - /// Defines whether unversioned task registrations can be used when no exact version match exists. + /// Controls when an unversioned task registration is used to serve a versioned request that has no exact + /// match. Only affects task names that have both an unversioned registration and at least one versioned + /// registration; otherwise the dispatch rules are unchanged. /// public enum UnversionedFallbackMode { /// - /// Only use an unversioned task registration for unversioned requests, or for versioned requests when - /// the task name has no versioned registrations. + /// Never fall back to an unversioned registration as a catch-all for unmatched versioned requests. This + /// is the default closed-set behavior: once a task name has at least one versioned registration, a + /// request for a version that has no exact match returns "not found" rather than dispatching to the + /// unversioned registration. Unversioned requests are still served by the unversioned registration, and + /// versioned requests still fall back to the unversioned registration when the task name has no + /// versioned registrations at all. /// Never = 0, /// - /// Use an unversioned task registration when no exact versioned registration exists for the requested task. + /// Fall back to the unversioned registration when no exact versioned match exists. An exact versioned + /// match still wins; only unmatched versioned requests dispatch to the unversioned registration as a + /// catch-all implementation. Use only when the unversioned implementation is replay-compatible with + /// every version it may receive. /// WhenNoExactMatch = 1, } From 4c3b3651a0f9382ffd2a37ee5dea647fd30eb91f Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Thu, 21 May 2026 12:31:20 -0700 Subject: [PATCH 04/15] Clean up code-analysis warnings on touched files Remove three pre-existing warnings on the two files this PR touches: - DurableTaskWorkerWorkItemFilters.cs: disambiguate the cref to UseWorkItemFilters by specifying the parameterless overload (was CS0419 ambiguous reference between the two extension method overloads). - DurableTaskWorkerOptions.cs: drop the extra blank line before ApplyTo (was SA1507 multiple-blank-lines). - DurableTaskWorkerOptions.cs: scope a #pragma warning disable CS0618 around the internal OrchestrationFilter forwarding in ApplyTo. The obsolete-annotated property still needs to be carried across options instances, but suppressing the warning is correct because ApplyTo is the experimental property's owner-side forwarding path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskWorkerOptions.cs | 3 ++- src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 2d21b602b..6383eb1c7 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -202,7 +202,6 @@ public DataConverter DataConverter /// internal bool DataConverterExplicitlySet { get; private set; } - /// /// Applies these option values to another. /// @@ -216,7 +215,9 @@ internal void ApplyTo(DurableTaskWorkerOptions other) other.MaximumTimerInterval = this.MaximumTimerInterval; other.EnableEntitySupport = this.EnableEntitySupport; other.Versioning = this.Versioning; +#pragma warning disable CS0618 // Internal forwarding of the experimental OrchestrationFilter property. other.OrchestrationFilter = this.OrchestrationFilter; +#pragma warning restore CS0618 other.Logging.UseLegacyCategories = this.Logging.UseLegacyCategories; } } diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index c86d67b06..dc8bfcccb 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -7,8 +7,8 @@ namespace Microsoft.DurableTask.Worker; /// A class that represents work item filters for a Durable Task Worker. These filters are passed to the backend /// and only work items matching the filters will be processed by the worker. If no filters are provided, /// the worker will process all work items. To opt-in to work item filtering, call -/// on the worker builder with either -/// explicit filters or auto-generated filters from the . +/// on the worker +/// builder with either explicit filters or auto-generated filters from the . /// public class DurableTaskWorkerWorkItemFilters { From 4ce5234b6d7bff7b9519949a75bb510ccff50f9d Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Thu, 21 May 2026 15:09:30 -0700 Subject: [PATCH 05/15] Drop redundant ASPNETCORE_URLS from DTS emulator docker command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dts-emulator image binds to port 8080 by default — confirmed by samples/DistributedTracingSample/docker-compose.yml which runs the same image without ASPNETCORE_URLS. Drop the redundant '-e ASPNETCORE_URLS=http://+:8080' so this sample's instructions stay minimal. Addresses review feedback at PR #731#discussion_r3284373466. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/UnversionedFallbackSample/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/UnversionedFallbackSample/README.md b/samples/UnversionedFallbackSample/README.md index e8f918536..ad2dcbc8c 100644 --- a/samples/UnversionedFallbackSample/README.md +++ b/samples/UnversionedFallbackSample/README.md @@ -21,7 +21,7 @@ This sample demonstrates opt-in unversioned fallback for per-task versioning. It ### 1. Start the DTS emulator ```bash -docker run --name durabletask-emulator -d -p 8080:8080 -p 8082:8082 -e ASPNETCORE_URLS=http://+:8080 mcr.microsoft.com/dts/dts-emulator:latest +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 to inspect the orchestrations and their versions. From 1809cddace9f4491f0f2fd9585712c74213dec19 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 10:06:03 -0700 Subject: [PATCH 06/15] Split unversioned fallback into orchestrator + activity, rename, log per dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the unversioned-fallback API surface: 1. Rename UnversionedFallbackMode.WhenNoExactMatch -> CatchAll. The original name was technically accurate but did not capture the actual delta from the existing implicit fallback (which also fires 'when no exact match' for unversioned-only names). CatchAll matches the user mental model — the unversioned registration is used as the catch-all for unmatched versioned requests on mixed names. 2. Add a scenario table in on UnversionedFallbackMode making the boundary cases between Never and CatchAll unmistakable (unversioned-only / mixed-with-match / mixed-without-match / versioned-only). 3. Split VersioningOptions.UnversionedFallback into OrchestratorUnversionedFallback and ActivityUnversionedFallback (both default Never). Replay risk is overwhelmingly orchestrator-side; the split lets users enable activity fallback (safer) without committing to orchestrator fallback (risky). Adding this split later would be a breaking change, so doing it before the feature ships. 4. Add per-dispatch Debug logs in DurableTaskFactory: EventId 608 for orchestrator dispatch to the unversioned registration, EventId 609 for activity dispatch. Pluming an optional ILoggerFactory through the new BuildFactory(workerOptions, loggerFactory) overload; existing overloads forward. 5. Split the existing EventId 606 warning into two: 606 for orchestrator fallback enabled, 607 for activity fallback enabled. Each fires only when the corresponding side is on; both fire when both are on. 6. Add blocks on both new properties documenting interactions with MatchStrategy (Strict / CurrentOrOlder / None) and clarifying that the setting applies regardless of MatchStrategy (unlike FailureStrategy). Plumbing changes: - DurableTaskFactory ctor takes the two split flags + optional ILoggerFactory. - DurableTaskRegistryExtensions.BuildFactory gains a loggerFactory overload. - DefaultDurableTaskWorkerBuilder resolves ILoggerFactory and passes it to BuildFactory; emits one or both warnings depending on which flags are on. - DurableTaskWorkerWorkItemFilters reads each flag independently for its respective filter set (orchestrator/activity), so widening one side does not implicitly widen the other. - UseVersioning extension propagates both new fields. Sample updates: - samples/UnversionedFallbackSample enables both flags (it specifically demos orchestrator catch-all) but the README calls out activity-only as the safer starting point. Tests: - Renamed existing tests to use CatchAll + the new split properties. - Added orchestrator-fallback-only test (verifies activity dispatch stays closed-set for mixed names) and the symmetric activity-only test. - Added per-dispatch Debug log assertions for both orchestrator and activity fallback paths. - Refactored the CapturingLoggerFactory test helper out of DefaultDurableTaskWorkerBuilderTests into a shared file so it can be used by the factory tests too. - Added Build_WithActivityUnversionedFallback_LogsActivityWarning and Build_WithBothFallbacksEnabled_LogsBothWarnings to exercise the split warning paths. All Worker.Tests (144) and Worker.Grpc.Tests (136) pass locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/UnversionedFallbackSample/Program.cs | 10 +- samples/UnversionedFallbackSample/README.md | 9 +- .../DefaultDurableTaskWorkerBuilder.cs | 17 ++- .../DurableTaskWorkerBuilderExtensions.cs | 3 +- src/Worker/Core/DurableTaskFactory.cs | 28 +++-- .../Core/DurableTaskRegistryExtensions.cs | 22 +++- src/Worker/Core/DurableTaskWorkerOptions.cs | 95 +++++++++++++--- .../Core/DurableTaskWorkerWorkItemFilters.cs | 17 ++- src/Worker/Core/Logs.cs | 22 +++- .../Core.Tests/CapturingLoggerFactory.cs | 45 ++++++++ .../DefaultDurableTaskWorkerBuilderTests.cs | 106 +++++++++++------- .../UseWorkItemFiltersTests.cs | 46 +++++++- ...rableTaskFactoryActivityVersioningTests.cs | 67 ++++++++++- .../DurableTaskFactoryVersioningTests.cs | 67 ++++++++++- .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 2 +- 15 files changed, 463 insertions(+), 93 deletions(-) create mode 100644 test/Worker/Core.Tests/CapturingLoggerFactory.cs diff --git a/samples/UnversionedFallbackSample/Program.cs b/samples/UnversionedFallbackSample/Program.cs index e15680af1..0e7bb598c 100644 --- a/samples/UnversionedFallbackSample/Program.cs +++ b/samples/UnversionedFallbackSample/Program.cs @@ -27,7 +27,11 @@ wb.AddTasks(tasks => tasks.AddAllGeneratedTasks()); wb.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions { - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + // 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); @@ -74,8 +78,8 @@ await host.StopAsync(); /// -/// The current implementation. With UnversionedFallback enabled, this unversioned registration handles every -/// requested SupportWorkflow version that does not have an exact explicit registration. +/// The current implementation. With OrchestratorUnversionedFallback enabled, this unversioned registration +/// handles every requested SupportWorkflow version that does not have an exact explicit registration. /// [DurableTask(nameof(SupportWorkflow))] public sealed class SupportWorkflow : TaskOrchestrator diff --git a/samples/UnversionedFallbackSample/README.md b/samples/UnversionedFallbackSample/README.md index ad2dcbc8c..060e87008 100644 --- a/samples/UnversionedFallbackSample/README.md +++ b/samples/UnversionedFallbackSample/README.md @@ -6,7 +6,7 @@ This sample demonstrates opt-in unversioned fallback for per-task versioning. It - `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`. +- 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. @@ -67,9 +67,10 @@ 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. +- 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 fallback if you are unsure. +- Unversioned fallback is opt-in. Without `CatchAll` on the corresponding side, a mixed unversioned plus versioned registration remains a closed set and unknown versions fail rather than falling back. +- Use orchestrator fallback 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 by allowing unmatched versions for logical names that have an unversioned catch-all registration on the enabled side. ## See also diff --git a/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs b/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs index 28a024af6..bd81e8e7c 100644 --- a/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs +++ b/src/Worker/Core/DependencyInjection/DefaultDurableTaskWorkerBuilder.cs @@ -56,19 +56,24 @@ public IHostedService Build(IServiceProvider serviceProvider) DurableTaskRegistry registry = serviceProvider.GetOptions(this.Name); DurableTaskWorkerOptions workerOptions = serviceProvider.GetOptions(this.Name); - if (workerOptions.Versioning?.UnversionedFallback - == DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch) + ILoggerFactory? loggerFactory = serviceProvider.GetService(); + if (loggerFactory is not null && workerOptions.Versioning is { } versioning) { - ILoggerFactory? loggerFactory = serviceProvider.GetService(); - if (loggerFactory is not null) + ILogger workerLogger = Logs.CreateWorkerLogger(loggerFactory); + if (versioning.OrchestratorUnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll) { - Logs.CreateWorkerLogger(loggerFactory).UnversionedFallbackEnabled(this.Name); + 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(workerOptions)); + serviceProvider, this.buildTarget, this.Name, registry.BuildFactory(workerOptions, loggerFactory)); } } diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index 3ca86560f..ac1b058f9 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -106,7 +106,8 @@ public static IDurableTaskWorkerBuilder UseVersioning(this IDurableTaskWorkerBui DefaultVersion = versionOptions.DefaultVersion, MatchStrategy = versionOptions.MatchStrategy, FailureStrategy = versionOptions.FailureStrategy, - UnversionedFallback = versionOptions.UnversionedFallback, + OrchestratorUnversionedFallback = versionOptions.OrchestratorUnversionedFallback, + ActivityUnversionedFallback = versionOptions.ActivityUnversionedFallback, }; }); return builder; diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index 8ba2533dd..a547b6968 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; namespace Microsoft.DurableTask.Worker; @@ -16,7 +17,9 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedTaskFactory readonly IDictionary> entities; readonly HashSet versionedOrchestratorNames; readonly HashSet versionedActivityNames; - readonly bool useUnversionedFallback; + readonly bool useOrchestratorUnversionedFallback; + readonly bool useActivityUnversionedFallback; + readonly ILogger? logger; /// /// Initializes a new instance of the class. @@ -24,21 +27,30 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedTaskFactory /// The activity factories. /// The orchestrator factories. /// The entity factories. - /// The unversioned fallback mode. + /// The unversioned fallback mode for orchestrators. + /// The unversioned fallback mode for activities. + /// Optional logger factory used to emit per-dispatch fallback diagnostics. internal DurableTaskFactory( IDictionary> activities, IDictionary> orchestrators, IDictionary> entities, - DurableTaskWorkerOptions.UnversionedFallbackMode unversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Never) + DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Never, + DurableTaskWorkerOptions.UnversionedFallbackMode activityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Never, + ILoggerFactory? loggerFactory = null) { this.activities = Check.NotNull(activities); this.orchestrators = Check.NotNull(orchestrators); this.entities = Check.NotNull(entities); - this.useUnversionedFallback = unversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch; + this.useOrchestratorUnversionedFallback = + orchestratorUnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; + this.useActivityUnversionedFallback = + activityUnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; + this.logger = loggerFactory is not null ? Logs.CreateWorkerLogger(loggerFactory) : null; // Snapshot the set of logical names that have at least one versioned registration. By default, this gates // unversioned fallback so a mixed versioned/unversioned name remains a closed set. Workers can opt in to - // allowing the unversioned registration to handle unmatched versions. + // allowing the unversioned registration to handle unmatched versions, independently for orchestrators + // and activities. this.versionedOrchestratorNames = new HashSet( this.orchestrators.Keys .Where(k => !string.IsNullOrWhiteSpace(k.Version)) @@ -70,9 +82,10 @@ public bool TryCreateActivity( // registration exists for the same logical name. Workers can also opt in to treating the unversioned // registration as a catch-all for unmatched versions. if (!string.IsNullOrWhiteSpace(version.Version) - && (this.useUnversionedFallback || !this.versionedActivityNames.Contains(name.Name)) + && (this.useActivityUnversionedFallback || !this.versionedActivityNames.Contains(name.Name)) && this.activities.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { + this.logger?.ActivityDispatchedToUnversionedFallback(name.Name, version.Version); activity = factory.Invoke(serviceProvider); return true; } @@ -105,9 +118,10 @@ public bool TryCreateOrchestrator( // registration exists for the same logical name. Workers can also opt in to treating the unversioned // registration as a catch-all for unmatched versions. if (!string.IsNullOrWhiteSpace(version.Version) - && (this.useUnversionedFallback || !this.versionedOrchestratorNames.Contains(name.Name)) + && (this.useOrchestratorUnversionedFallback || !this.versionedOrchestratorNames.Contains(name.Name)) && this.orchestrators.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { + this.logger?.OrchestratorDispatchedToUnversionedFallback(name.Name, version.Version); orchestrator = factory.Invoke(serviceProvider); return true; } diff --git a/src/Worker/Core/DurableTaskRegistryExtensions.cs b/src/Worker/Core/DurableTaskRegistryExtensions.cs index 799e0af4c..ae7c3db3f 100644 --- a/src/Worker/Core/DurableTaskRegistryExtensions.cs +++ b/src/Worker/Core/DurableTaskRegistryExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Extensions.Logging; + namespace Microsoft.DurableTask.Worker; /// @@ -14,23 +16,33 @@ static class DurableTaskRegistryExtensions /// The registry to build. /// The built factory. public static IDurableTaskFactory BuildFactory(this DurableTaskRegistry registry) - => registry.BuildFactory(null); + => registry.BuildFactory(workerOptions: null, loggerFactory: null); /// /// Builds a into a . /// /// The registry to build. /// The worker options to use when building the factory. + /// Optional logger factory used to emit per-dispatch fallback diagnostics. /// The built factory. - public static IDurableTaskFactory BuildFactory(this DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) + public static IDurableTaskFactory BuildFactory( + this DurableTaskRegistry registry, + DurableTaskWorkerOptions? workerOptions, + ILoggerFactory? loggerFactory = null) { Check.NotNull(registry); - DurableTaskWorkerOptions.UnversionedFallbackMode unversionedFallback = - workerOptions?.Versioning?.UnversionedFallback ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Never; + DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorFallback = + workerOptions?.Versioning?.OrchestratorUnversionedFallback + ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Never; + DurableTaskWorkerOptions.UnversionedFallbackMode activityFallback = + workerOptions?.Versioning?.ActivityUnversionedFallback + ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Never; return new DurableTaskFactory( registry.ActivitiesByVersion, registry.OrchestratorsByVersion, registry.Entities, - unversionedFallback); + orchestratorFallback, + activityFallback, + loggerFactory); } } diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 6383eb1c7..e11d48464 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -54,6 +54,31 @@ public enum VersionFailureStrategy /// match. Only affects task names that have both an unversioned registration and at least one versioned /// registration; otherwise the dispatch rules are unchanged. /// + /// + /// The matrix below summarizes dispatch for a versioned request: + /// + /// + /// Registration shape for the task name + /// Result with vs. + /// + /// + /// Only unversioned registration + /// Both modes dispatch to the unversioned registration. + /// + /// + /// Mixed (versioned + unversioned), exact version match + /// Both modes dispatch to the exact-matching versioned registration. + /// + /// + /// Mixed (versioned + unversioned), no exact version match + /// returns "not found"; dispatches to the unversioned registration. + /// + /// + /// Only versioned registrations, no exact version match + /// Both modes return "not found" (no unversioned implementation exists). + /// + /// + /// public enum UnversionedFallbackMode { /// @@ -67,12 +92,12 @@ public enum UnversionedFallbackMode Never = 0, /// - /// Fall back to the unversioned registration when no exact versioned match exists. An exact versioned - /// match still wins; only unmatched versioned requests dispatch to the unversioned registration as a - /// catch-all implementation. Use only when the unversioned implementation is replay-compatible with - /// every version it may receive. + /// Use the unversioned registration as a catch-all when no exact versioned match exists. An exact + /// versioned match still wins; only unmatched versioned requests dispatch to the unversioned + /// registration. Use only when the unversioned implementation is replay-compatible with every version it + /// may receive. /// - WhenNoExactMatch = 1, + CatchAll = 1, } /// @@ -272,25 +297,65 @@ public class VersioningOptions public VersionFailureStrategy FailureStrategy { get; set; } = VersionFailureStrategy.Reject; /// - /// Gets or sets whether unversioned task registrations can be used when no exact version match exists. + /// Gets or sets whether the unversioned orchestrator registration acts as a catch-all for unmatched + /// versioned orchestrator requests. /// /// /// - /// The default value, , preserves the closed-set behavior for - /// mixed unversioned and versioned registrations. In this mode, a request for an unknown version does not - /// fall back to the unversioned registration once any versioned registration exists for the same task name. + /// Defaults to . See + /// for the dispatch matrix. /// /// - /// When set to , an exact versioned registration still - /// wins, but unmatched versioned requests can use the unversioned registration as a catch-all implementation. + /// Replay risk is highest on the orchestrator side: orchestrators are deterministic and rehydrate + /// state from history on every replay. Enable only when the unversioned orchestrator implementation + /// is replay-compatible with every version it may receive. Replaying existing histories against an + /// incompatible implementation can cause non-determinism faults or deserialization failures. /// /// - /// WARNING: Only enable this mode when the unversioned implementation is compatible with every version it may - /// receive. Replaying an existing orchestration or activity history against a different implementation can - /// cause non-determinism or deserialization failures. + /// Interaction with other versioning options: /// + /// + /// Unlike , this setting applies regardless of + /// . The factory-level fallback runs whether or not the pre-dispatch + /// versioning gate is active. + /// When is , + /// instance-version mismatches are rejected by the pre-dispatch versioning gate before the factory + /// is consulted, so this setting has no observable effect. + /// When is + /// , the pre-dispatch versioning gate rejects + /// orchestration versions newer than . Fallback applies only to versions accepted + /// by the gate; newer-than-worker versions are still subject to + /// . + /// + /// + public UnversionedFallbackMode OrchestratorUnversionedFallback { get; set; } = UnversionedFallbackMode.Never; + + /// + /// Gets or sets whether the unversioned activity registration acts as a catch-all for unmatched + /// versioned activity requests. + /// + /// + /// + /// Defaults to . See + /// for the dispatch matrix. + /// + /// + /// Activities are stateless and do not replay history, so this setting carries less risk than + /// . The main concern is input contract compatibility: + /// ensure the unversioned activity implementation accepts the input shapes produced by every version + /// of the calling orchestrators that may schedule it. + /// + /// + /// Interaction with other versioning options: + /// + /// + /// Unlike , this setting applies regardless of + /// . + /// applies to orchestration instance versions, not + /// activity scheduling versions, so it does not gate activity dispatch. + /// /// - public UnversionedFallbackMode UnversionedFallback { get; set; } = UnversionedFallbackMode.Never; + public UnversionedFallbackMode ActivityUnversionedFallback { get; set; } = UnversionedFallbackMode.Never; } /// diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index dc8bfcccb..e8de73cc6 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -43,8 +43,12 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict ? [workerOptions.Versioning.Version ?? string.Empty] : null; - bool useUnversionedFallback = - workerOptions?.Versioning?.UnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch; + bool useOrchestratorUnversionedFallback = + workerOptions?.Versioning?.OrchestratorUnversionedFallback + == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; + bool useActivityUnversionedFallback = + workerOptions?.Versioning?.ActivityUnversionedFallback + == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; // Orchestration filters group registrations by logical name and emit the concrete distinct // version set actually registered (treating null/unversioned as ""). Strict mode overrides @@ -54,12 +58,16 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable // backend can deliver versioned work items the factory can handle. Otherwise, emitting the // concrete version set prevents the backend from streaming work items the worker would then // reject after the fact. + // + // Orchestrator and activity fallback are configured independently, so each filter set + // consults its own flag. List orchestrationFilters = registry.OrchestratorsByVersion .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { IReadOnlyList versions = - strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useUnversionedFallback); + strictWorkerVersions + ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useOrchestratorUnversionedFallback); return new OrchestrationFilter { @@ -74,7 +82,8 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable .Select(group => { IReadOnlyList versions = - strictWorkerVersions ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useUnversionedFallback); + strictWorkerVersions + ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useActivityUnversionedFallback); return new ActivityFilter { diff --git a/src/Worker/Core/Logs.cs b/src/Worker/Core/Logs.cs index 6a38ea398..eef4e1b0a 100644 --- a/src/Worker/Core/Logs.cs +++ b/src/Worker/Core/Logs.cs @@ -38,8 +38,26 @@ static partial class Logs [LoggerMessage( EventId = 606, Level = LogLevel.Warning, - Message = "Unversioned fallback mode is enabled for Durable Task worker '{workerName}'. Unmatched versioned orchestrations and activities may run on unversioned registrations; ensure those implementations are replay-compatible with every version they may receive. Replaying existing histories against a different implementation can cause non-determinism or deserialization failures.")] - public static partial void UnversionedFallbackEnabled(this ILogger logger, string workerName); + Message = "Orchestrator unversioned fallback is enabled for Durable Task worker '{workerName}'. Unmatched versioned orchestrators may run on the unversioned registration; ensure that implementation is replay-compatible with every version it may receive. Replaying existing histories against a different implementation can cause non-determinism or deserialization failures.")] + public static partial void OrchestratorUnversionedFallbackEnabled(this ILogger logger, string workerName); + + [LoggerMessage( + EventId = 607, + Level = LogLevel.Warning, + Message = "Activity unversioned fallback is enabled for Durable Task worker '{workerName}'. Unmatched versioned activities may run on the unversioned registration; ensure that implementation accepts the input shapes produced by every version of the calling orchestrators.")] + public static partial void ActivityUnversionedFallbackEnabled(this ILogger logger, string workerName); + + [LoggerMessage( + EventId = 608, + Level = LogLevel.Debug, + Message = "Orchestrator '{Name}' version '{RequestedVersion}' had no exact match; dispatching to the unversioned registration.")] + public static partial void OrchestratorDispatchedToUnversionedFallback(this ILogger logger, string name, string requestedVersion); + + [LoggerMessage( + EventId = 609, + Level = LogLevel.Debug, + Message = "Activity '{Name}' version '{RequestedVersion}' had no exact match; dispatching to the unversioned registration.")] + public static partial void ActivityDispatchedToUnversionedFallback(this ILogger logger, string name, string requestedVersion); /// /// Creates a logger named "Microsoft.DurableTask.Worker" with an optional subcategory. diff --git a/test/Worker/Core.Tests/CapturingLoggerFactory.cs b/test/Worker/Core.Tests/CapturingLoggerFactory.cs new file mode 100644 index 000000000..46f9afa0d --- /dev/null +++ b/test/Worker/Core.Tests/CapturingLoggerFactory.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.Worker.Tests; + +/// +/// In-memory that captures every log call so tests can assert on level + message. +/// +sealed class CapturingLoggerFactory : ILoggerFactory +{ + public List<(LogLevel Level, string Message)> Logs { get; } = []; + + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this.Logs); + + public void Dispose() + { + } + + sealed class CapturingLogger(List<(LogLevel Level, string Message)> logs) : ILogger + { + readonly List<(LogLevel Level, string Message)> logs = logs; + + public IDisposable? BeginScope(TState state) + where TState : notnull + => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + this.logs.Add((logLevel, formatter(state, exception))); + } + } +} diff --git a/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs b/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs index 97b527988..ff8247e27 100644 --- a/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/DefaultDurableTaskWorkerBuilderTests.cs @@ -87,7 +87,7 @@ public void Build_Target_Built() } [Fact] - public void Build_WithUnversionedFallback_LogsWarning() + public void Build_WithOrchestratorUnversionedFallback_LogsOrchestratorWarning() { // Arrange CapturingLoggerFactory loggerFactory = new(); @@ -100,7 +100,7 @@ public void Build_WithUnversionedFallback_LogsWarning() }; builder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions { - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, }); // Act @@ -109,11 +109,73 @@ public void Build_WithUnversionedFallback_LogsWarning() // Assert loggerFactory.Logs.Should().Contain(log => log.Level == LogLevel.Warning - && log.Message.Contains("unversioned", StringComparison.OrdinalIgnoreCase) - && log.Message.Contains("fallback", StringComparison.OrdinalIgnoreCase) + && log.Message.Contains("Orchestrator unversioned fallback", StringComparison.OrdinalIgnoreCase) && log.Message.Contains("replay", StringComparison.OrdinalIgnoreCase) && log.Message.Contains("non-determinism", StringComparison.OrdinalIgnoreCase) && log.Message.Contains("deserialization", StringComparison.OrdinalIgnoreCase)); + loggerFactory.Logs.Should().NotContain(log => + log.Level == LogLevel.Warning + && log.Message.Contains("Activity unversioned fallback", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Build_WithActivityUnversionedFallback_LogsActivityWarning() + { + // Arrange + CapturingLoggerFactory loggerFactory = new(); + ServiceCollection services = new(); + services.AddOptions(); + services.AddSingleton(loggerFactory); + DefaultDurableTaskWorkerBuilder builder = new("test", services) + { + BuildTarget = typeof(GoodBuildTarget), + }; + builder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }); + + // Act + builder.Build(services.BuildServiceProvider()); + + // Assert + loggerFactory.Logs.Should().Contain(log => + log.Level == LogLevel.Warning + && log.Message.Contains("Activity unversioned fallback", StringComparison.OrdinalIgnoreCase) + && log.Message.Contains("input shapes", StringComparison.OrdinalIgnoreCase)); + loggerFactory.Logs.Should().NotContain(log => + log.Level == LogLevel.Warning + && log.Message.Contains("Orchestrator unversioned fallback", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Build_WithBothFallbacksEnabled_LogsBothWarnings() + { + // Arrange + CapturingLoggerFactory loggerFactory = new(); + ServiceCollection services = new(); + services.AddOptions(); + services.AddSingleton(loggerFactory); + DefaultDurableTaskWorkerBuilder builder = new("test", services) + { + BuildTarget = typeof(GoodBuildTarget), + }; + builder.UseVersioning(new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }); + + // Act + builder.Build(services.BuildServiceProvider()); + + // Assert + loggerFactory.Logs.Should().Contain(log => + log.Level == LogLevel.Warning + && log.Message.Contains("Orchestrator unversioned fallback", StringComparison.OrdinalIgnoreCase)); + loggerFactory.Logs.Should().Contain(log => + log.Level == LogLevel.Warning + && log.Message.Contains("Activity unversioned fallback", StringComparison.OrdinalIgnoreCase)); } class BadBuildTarget : BackgroundService @@ -161,40 +223,4 @@ class CustomDataConverter : DataConverter class GoodBuildTargetOptions : DurableTaskWorkerOptions { } - - sealed class CapturingLoggerFactory : ILoggerFactory - { - public List<(LogLevel Level, string Message)> Logs { get; } = []; - - public void AddProvider(ILoggerProvider provider) - { - } - - public ILogger CreateLogger(string categoryName) => new CapturingLogger(this.Logs); - - public void Dispose() - { - } - } - - sealed class CapturingLogger(List<(LogLevel Level, string Message)> logs) : ILogger - { - readonly List<(LogLevel Level, string Message)> logs = logs; - - public IDisposable? BeginScope(TState state) - where TState : notnull - => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log( - LogLevel logLevel, - EventId eventId, - TState state, - Exception? exception, - Func formatter) - { - this.logs.Add((logLevel, formatter(state, exception))); - } - } } diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index a9eb07371..8ad60c0f5 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -381,7 +381,7 @@ public void WorkItemFilters_UnversionedFallbackWithMixedOrchestrators_EmitsWildc { options.Versioning = new DurableTaskWorkerOptions.VersioningOptions { - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, }; }); builder.UseWorkItemFilters(); @@ -399,6 +399,45 @@ public void WorkItemFilters_UnversionedFallbackWithMixedOrchestrators_EmitsWildc actual.Orchestrations[0].Versions.Should().BeEmpty(); } + [Fact] + public void WorkItemFilters_OrchestratorFallbackOnly_DoesNotWidenActivityFilter() + { + // Arrange — orchestrator fallback ON, activity fallback OFF. Mixed orchestrator name widens to + // wildcard; mixed activity name must still emit the concrete version list because the worker + // refuses activity unversioned-fallback for that name. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Versions.Should().BeEmpty(); + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Versions.Should().BeEquivalentTo([string.Empty, "v2"]); + } + [Fact] public void WorkItemFilters_VersionedActivities_GroupVersionsByLogicalName() { @@ -471,7 +510,7 @@ public void WorkItemFilters_UnversionedFallbackWithMixedActivities_EmitsWildcard { options.Versioning = new DurableTaskWorkerOptions.VersioningOptions { - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, }; }); builder.UseWorkItemFilters(); @@ -509,7 +548,8 @@ public void WorkItemFilters_UnversionedFallbackWithVersioningStrict_UsesConfigur { Version = "1.0", MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, }; }); builder.UseWorkItemFilters(); diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs index ad6baa8b4..16b654de8 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Extensions.Logging; + namespace Microsoft.DurableTask.Worker.Tests; public class DurableTaskFactoryActivityVersioningTests @@ -141,7 +143,7 @@ public void TryCreateActivity_WithMixedRegistrationsAndUnversionedFallback_UsesU { Versioning = new DurableTaskWorkerOptions.VersioningOptions { - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, }, }; IDurableTaskFactory factory = registry.BuildFactory(workerOptions); @@ -158,6 +160,69 @@ public void TryCreateActivity_WithMixedRegistrationsAndUnversionedFallback_UsesU activity.Should().BeOfType(); } + [Fact] + public void TryCreateActivity_WithOrchestratorFallbackOnly_DoesNotEnableActivityFallback() + { + // Arrange — only the orchestrator-side flag is enabled. Activity dispatch must still be closed-set + // for mixed names; the split into two properties must isolate the two sides independently. + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v9"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeFalse(); + activity.Should().BeNull(); + } + + [Fact] + public void TryCreateActivity_WithActivityFallback_LogsDispatchAtDebug() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }, + }; + CapturingLoggerFactory loggerFactory = new(); + IDurableTaskFactory factory = registry.BuildFactory(workerOptions, loggerFactory); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v9"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + loggerFactory.Logs.Should().Contain(log => + log.Level == LogLevel.Debug + && log.Message.Contains("InvoiceActivity", StringComparison.Ordinal) + && log.Message.Contains("v9", StringComparison.Ordinal) + && log.Message.Contains("unversioned", StringComparison.OrdinalIgnoreCase)); + } + [DurableTask("InvoiceActivity", Version = "v1")] sealed class InvoiceActivityV1 : TaskActivity { diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index 35150fa80..610a5f496 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Extensions.Logging; + namespace Microsoft.DurableTask.Worker.Tests; public class DurableTaskFactoryVersioningTests @@ -124,7 +126,7 @@ public void TryCreateOrchestrator_WithMixedRegistrationsAndUnversionedFallback_U { Versioning = new DurableTaskWorkerOptions.VersioningOptions { - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, }, }; IDurableTaskFactory factory = registry.BuildFactory(workerOptions); @@ -141,6 +143,69 @@ public void TryCreateOrchestrator_WithMixedRegistrationsAndUnversionedFallback_U orchestrator.Should().BeOfType(); } + [Fact] + public void TryCreateOrchestrator_WithActivityFallbackOnly_DoesNotEnableOrchestratorFallback() + { + // Arrange — only the activity-side flag is enabled. Orchestrator dispatch must still be closed-set + // for mixed names; otherwise the split into two properties does not actually isolate the two sides. + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v9"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeFalse(); + orchestrator.Should().BeNull(); + } + + [Fact] + public void TryCreateOrchestrator_WithOrchestratorFallback_LogsDispatchAtDebug() + { + // Arrange + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }, + }; + CapturingLoggerFactory loggerFactory = new(); + IDurableTaskFactory factory = registry.BuildFactory(workerOptions, loggerFactory); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v9"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + loggerFactory.Logs.Should().Contain(log => + log.Level == LogLevel.Debug + && log.Message.Contains("InvoiceWorkflow", StringComparison.Ordinal) + && log.Message.Contains("v9", StringComparison.Ordinal) + && log.Message.Contains("unversioned", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void TryCreateOrchestrator_WithOnlyUnversionedRegistration_FallsBackForVersionedRequest() { diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index 8c527c2ea..e46baff38 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -530,7 +530,7 @@ public async Task OnRunOrchestratorAsync_StrictVersioningWithUnversionedFallback Version = "1.0", MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, FailureStrategy = DurableTaskWorkerOptions.VersionFailureStrategy.Reject, - UnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.WhenNoExactMatch, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, }, Logging = { UseLegacyCategories = false }, }; From 925b115dc5aa167bf972c3c0af717b44e1a9c162 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 10:09:48 -0700 Subject: [PATCH 07/15] Address Copilot review nits on workitemfilters cref and timing comment 1. DurableTaskWorkerWorkItemFilters.cs: the class doc said callers can pass 'either explicit filters or auto-generated filters' but the only pointed at the parameterless overload. Reference both UseWorkItemFilters overloads so the cref matches the prose. 2. WorkItemStreamConsumerTests.cs (PerItem_HeartbeatReset_KeepsTimerAlive): the timing comment said '1000ms + 1500ms = 2500ms total before the 2nd item is written,' but the test also awaits firstItemProcessed between the two delays, which adds variable processing time. Clarify the comment to say 'at least 2500ms (plus the time to process the 1st item).' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Core/DurableTaskWorkerWorkItemFilters.cs | 6 ++++-- .../Grpc.Tests/WorkItemStreamConsumerTests.cs | 18 +++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index e8de73cc6..b8b409c35 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -7,8 +7,10 @@ namespace Microsoft.DurableTask.Worker; /// A class that represents work item filters for a Durable Task Worker. These filters are passed to the backend /// and only work items matching the filters will be processed by the worker. If no filters are provided, /// the worker will process all work items. To opt-in to work item filtering, call -/// on the worker -/// builder with either explicit filters or auto-generated filters from the . +/// for the +/// auto-generated filters from the worker's , or +/// +/// to supply explicit filters. /// public class DurableTaskWorkerWorkItemFilters { diff --git a/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs b/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs index 67fb2b21e..6e4ba5722 100644 --- a/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs +++ b/test/Worker/Grpc.Tests/WorkItemStreamConsumerTests.cs @@ -143,13 +143,17 @@ public async Task PerItem_HeartbeatReset_KeepsTimerAlive() // Synchronize on the first item actually being processed so the second delay is measured from // the consumer's timer reset instead of from the test thread's write timing. // - // Timings are sized to leave ~500ms of slack on both sides of the assertion so the test is - // robust to thread-pool scheduling jitter on loaded CI runners: - // - first delay (1000ms) + second delay (1500ms) = 2500ms total before 2nd item is written. - // - Without the per-item timer reset, the 2000ms original timer would have fired at 2000ms, - // leaving a ~500ms margin before the 2nd item is written (proves the test exercises the reset). - // - With the per-item reset, the new timer fires at first-delay + jitter + 2000ms, - // leaving ~500ms margin between the 2nd item write and the new timer expiry. + // Timing budget (with 500ms slack on both sides of the assertion to survive CI scheduling jitter): + // - The consumer arms a 2000ms silent-disconnect timer. + // - Test thread delays 1000ms, then writes the 1st item. + // - Test thread awaits firstItemProcessed; the consumer dequeues the 1st item and re-arms the + // timer at T = 1000ms + (small, variable processing delay). + // - Test thread delays another 1500ms, then writes the 2nd item. + // - Total elapsed before the 2nd write is at least 2500ms (1000 + 1500) plus the variable + // time to process the 1st item — well past the original 2000ms timer, which proves the test + // exercises the per-item reset path. + // - After the reset, the new timer fires ~2000ms after the 1st item was dequeued, leaving + // ~500ms margin between the 2nd item write and the new timer expiry. Channel channel = Channel.CreateUnbounded(); TimeSpan timeout = TimeSpan.FromMilliseconds(2000); TaskCompletionSource firstItemProcessed = new(TaskCreationOptions.RunContinuationsAsynchronously); From 077bd8d3846f5661ecd94f7c20baa0c45e9e9eb2 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 11:15:57 -0700 Subject: [PATCH 08/15] Stop leaking worker dispatch contract into TaskOptions.Version doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskOptions lives in the client-side Abstractions layer (Worker references Abstractions, not the reverse), so referencing worker-side dispatch rules in this XML doc is both a layering smell and impossible to keep accurate as worker dispatch evolves (e.g. the new OrchestratorUnversionedFallback / ActivityUnversionedFallback options). The pre-existing wording even predates the unversioned-fallback feature — the leak is older than this PR and just got slightly worse when this PR added 'unless unversioned fallback is explicitly enabled on the worker.' Keep only the genuinely client-side scheduling semantics: - null = inherit from the scheduling orchestration instance (unchanged). - non-null = explicit override of the inherited version (unchanged). - Replace the dispatch-contract sentence with an honest 'the worker resolves the scheduled version according to its own versioning configuration; that resolution is not visible to the scheduling client.' Addresses review point #7 on PR #731 (client-side visibility / TaskOptions.Version leak). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/TaskOptions.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index 78f63a14e..b928dabc0 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -62,9 +62,11 @@ public TaskOptions(TaskOptions options) /// /// /// When non-null (including ), the task is scheduled with the - /// specified version explicitly. The worker dispatches to the registered (name, version) exactly; - /// when no exact match exists, it falls back to an unversioned registration only when the name has no - /// versioned registrations at all, unless unversioned fallback is explicitly enabled on the worker. + /// specified version explicitly, overriding any inherited version. + /// + /// + /// 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. /// /// public TaskVersion? Version { get; init; } From 887bc83d6cfe5dac419d1bf5e945a0f8079712fe Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 11:42:46 -0700 Subject: [PATCH 09/15] Document cross-environment version dispatch drift on TaskOptions.Version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit removed the misleading worker-side dispatch contract from TaskOptions.Version, but Chris's drift concern was broader: the same TaskOptions.Version value can dispatch to different registrations in different deployments depending on each worker's UnversionedFallback configuration, and there is no schedule-time signal. Add a fourth framing this as a deployment-time policy decision and pointing users at the worker's startup and per-dispatch diagnostic logs (EventIds 606/607 startup, 608/609 per-dispatch — kept textual so the client-side doc doesn't reference worker-side constants) for verification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Abstractions/TaskOptions.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index b928dabc0..3452e3d7d 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -68,6 +68,11 @@ public TaskOptions(TaskOptions options) /// 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. /// + /// + /// 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. + /// /// public TaskVersion? Version { get; init; } From 23e6bee8a5ef31b48daf3355cc11578085b35ac6 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 11:56:11 -0700 Subject: [PATCH 10/15] Correct OrchestratorUnversionedFallback strict-mode interaction note The previous doc claimed that with MatchStrategy = Strict, the fallback setting 'has no observable effect.' That's too strong: Strict only rejects instance versions that don't equal the worker's configured Version. For instances whose version passes the gate, factory dispatch still occurs, and CatchAll can still affect the outcome if the registry has no exact-version match for that (name, version) pair but does have an unversioned registration. Worked example showing the gap: - Worker version = 1.0, MatchStrategy = Strict, OrchestratorUnversionedFallback = CatchAll - Registry: [X v=2.0] + [X] (unversioned) - Instance arrives with version '1.0' - Strict gate passes (instance version == worker version) - Factory lookup '(X, 1.0)' misses; with CatchAll the unversioned [X] is used. The new wording matches the existing CurrentOrOlder bullet's framing: 'Fallback does not bypass the gate, but can still apply post-gate to instances that pass it but lack an exact-version registration.' Addresses Copilot review #4348162532 inline comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskWorkerOptions.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index e11d48464..68f1b71be 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -319,8 +319,9 @@ public class VersioningOptions /// . The factory-level fallback runs whether or not the pre-dispatch /// versioning gate is active. /// When is , - /// instance-version mismatches are rejected by the pre-dispatch versioning gate before the factory - /// is consulted, so this setting has no observable effect. + /// the pre-dispatch versioning gate rejects instance versions that don't equal the worker's + /// configured . Fallback does not bypass the gate, but can still apply post-gate + /// to instances that pass it but lack an exact-version registration. /// When is /// , the pre-dispatch versioning gate rejects /// orchestration versions newer than . Fallback applies only to versions accepted From 1c5ac73c195c8ac1355ed3c0c1b18f01ceb11239 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 13:36:41 -0700 Subject: [PATCH 11/15] Add UnversionedFallbackMode.StrictExactOnly; rename Never -> Implicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review point #8 on PR #731 (Never* asterisk). The original Never name was misleading because the long-standing implicit unversioned-only fallback continued to fire under that mode; Never only disabled the new opt-in catch-all behavior on mixed names. This commit makes the asterisk go away in two ways: 1. Rename Never -> Implicit. Implicit honestly describes the actual behavior: 'fall back implicitly when the name has no versioned siblings.' Numeric value 0 is preserved. An [Obsolete] alias keeps the old Never name source-compatible for anyone building against the preview branch. 2. Add StrictExactOnly = 2. This mode disables EVERY fallback path including the implicit one — versioned requests must have an exact (name, version) registration. Use case: callers want stale or bogus version values from upstream clients to fail loudly instead of silently landing on the unversioned implementation. Final dispatch matrix for versioned requests: | Registry shape | Implicit | CatchAll | StrictExactOnly | | -------------- | -------- | -------- | --------------- | | Only unversioned | unversioned | unversioned | not found | | Mixed, exact match | exact | exact | exact | | Mixed, no exact match | not found | unversioned | not found | | Only versioned, no exact match | not found | not found | not found | Unversioned requests are unaffected by the mode — they always dispatch to the unversioned registration when one exists (exact-match path with the empty-version key). Implementation: - DurableTaskFactory stores the full UnversionedFallbackMode per side (orchestrator/activity) instead of two bools. A small static helper ShouldUseUnversionedFallback(mode, versionedNames, requestedName) centralizes the dispatch decision. - DurableTaskRegistryExtensions defaults changed from Never -> Implicit. - DurableTaskWorkerWorkItemFilters.GetFilterVersions now takes the full mode. Under StrictExactOnly the filter emits the concrete registered version set instead of widening to a wildcard for unversioned-only names — otherwise the backend would deliver versioned work items the factory will reject after the fact. - DefaultDurableTaskWorkerBuilder warnings still gated on CatchAll only; StrictExactOnly is an opt-in tightening, not a risky relaxation. Known limitation (documented in the OrchestratorUnversionedFallback remarks and covered by WorkItemFilters_StrictMatchOverridesStrictExactOnly_KnownLimitation): when combined with MatchStrategy = Strict, the existing strict-override emits the worker's configured Version for every name regardless of whether the factory can resolve it. Under StrictExactOnly this can result in the backend delivering work items the worker will reject. A proper fix requires per-name dispatch-capability analysis and is out of scope here. The behavior is captured by the new test so it doesn't regress silently. Rubber-duck review consulted before commit; per-side independence test strengthened (asymmetric explicit modes + mixed activity registry) per that feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskFactory.cs | 50 ++++---- .../Core/DurableTaskRegistryExtensions.cs | 4 +- src/Worker/Core/DurableTaskWorkerOptions.cs | 107 +++++++++++------- .../Core/DurableTaskWorkerWorkItemFilters.cs | 60 ++++++---- .../UseWorkItemFiltersTests.cs | 101 +++++++++++++++++ ...rableTaskFactoryActivityVersioningTests.cs | 85 ++++++++++++++ .../DurableTaskFactoryVersioningTests.cs | 86 ++++++++++++++ 7 files changed, 409 insertions(+), 84 deletions(-) diff --git a/src/Worker/Core/DurableTaskFactory.cs b/src/Worker/Core/DurableTaskFactory.cs index a547b6968..923053d72 100644 --- a/src/Worker/Core/DurableTaskFactory.cs +++ b/src/Worker/Core/DurableTaskFactory.cs @@ -17,8 +17,8 @@ sealed class DurableTaskFactory : IDurableTaskFactory2, IVersionedTaskFactory readonly IDictionary> entities; readonly HashSet versionedOrchestratorNames; readonly HashSet versionedActivityNames; - readonly bool useOrchestratorUnversionedFallback; - readonly bool useActivityUnversionedFallback; + readonly DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorFallbackMode; + readonly DurableTaskWorkerOptions.UnversionedFallbackMode activityFallbackMode; readonly ILogger? logger; /// @@ -34,23 +34,21 @@ internal DurableTaskFactory( IDictionary> activities, IDictionary> orchestrators, IDictionary> entities, - DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Never, - DurableTaskWorkerOptions.UnversionedFallbackMode activityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Never, + DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit, + DurableTaskWorkerOptions.UnversionedFallbackMode activityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit, ILoggerFactory? loggerFactory = null) { this.activities = Check.NotNull(activities); this.orchestrators = Check.NotNull(orchestrators); this.entities = Check.NotNull(entities); - this.useOrchestratorUnversionedFallback = - orchestratorUnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; - this.useActivityUnversionedFallback = - activityUnversionedFallback == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; + this.orchestratorFallbackMode = orchestratorUnversionedFallback; + this.activityFallbackMode = activityUnversionedFallback; this.logger = loggerFactory is not null ? Logs.CreateWorkerLogger(loggerFactory) : null; - // Snapshot the set of logical names that have at least one versioned registration. By default, this gates - // unversioned fallback so a mixed versioned/unversioned name remains a closed set. Workers can opt in to - // allowing the unversioned registration to handle unmatched versions, independently for orchestrators - // and activities. + // Snapshot the set of logical names that have at least one versioned registration. Used by the + // Implicit fallback mode to recognize "unversioned-only" names, where a versioned request is allowed + // to resolve through the unversioned registration. CatchAll widens this for mixed names; StrictExactOnly + // disables fallback entirely. this.versionedOrchestratorNames = new HashSet( this.orchestrators.Keys .Where(k => !string.IsNullOrWhiteSpace(k.Version)) @@ -78,11 +76,10 @@ public bool TryCreateActivity( return true; } - // Unversioned registrations remain the compatibility fallback for a versioned request when no versioned - // registration exists for the same logical name. Workers can also opt in to treating the unversioned - // registration as a catch-all for unmatched versions. + // Resolve a versioned request through the unversioned registration when the mode allows it. + // See UnversionedFallbackMode for the dispatch matrix. if (!string.IsNullOrWhiteSpace(version.Version) - && (this.useActivityUnversionedFallback || !this.versionedActivityNames.Contains(name.Name)) + && ShouldUseUnversionedFallback(this.activityFallbackMode, this.versionedActivityNames, name.Name) && this.activities.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { this.logger?.ActivityDispatchedToUnversionedFallback(name.Name, version.Version); @@ -114,11 +111,10 @@ public bool TryCreateOrchestrator( return true; } - // Unversioned registrations remain the compatibility fallback for a versioned request when no versioned - // registration exists for the same logical name. Workers can also opt in to treating the unversioned - // registration as a catch-all for unmatched versions. + // Resolve a versioned request through the unversioned registration when the mode allows it. + // See UnversionedFallbackMode for the dispatch matrix. if (!string.IsNullOrWhiteSpace(version.Version) - && (this.useOrchestratorUnversionedFallback || !this.versionedOrchestratorNames.Contains(name.Name)) + && ShouldUseUnversionedFallback(this.orchestratorFallbackMode, this.versionedOrchestratorNames, name.Name) && this.orchestrators.TryGetValue(new TaskVersionKey(name, default(TaskVersion)), out factory)) { this.logger?.OrchestratorDispatchedToUnversionedFallback(name.Name, version.Version); @@ -148,4 +144,18 @@ public bool TryCreateEntity( entity = null; return false; } + + static bool ShouldUseUnversionedFallback( + DurableTaskWorkerOptions.UnversionedFallbackMode mode, + HashSet versionedNames, + string requestedName) + { + return mode switch + { + DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly => false, + DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll => true, + DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit => !versionedNames.Contains(requestedName), + _ => !versionedNames.Contains(requestedName), + }; + } } diff --git a/src/Worker/Core/DurableTaskRegistryExtensions.cs b/src/Worker/Core/DurableTaskRegistryExtensions.cs index ae7c3db3f..4e5b5b4ee 100644 --- a/src/Worker/Core/DurableTaskRegistryExtensions.cs +++ b/src/Worker/Core/DurableTaskRegistryExtensions.cs @@ -33,10 +33,10 @@ public static IDurableTaskFactory BuildFactory( Check.NotNull(registry); DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorFallback = workerOptions?.Versioning?.OrchestratorUnversionedFallback - ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Never; + ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit; DurableTaskWorkerOptions.UnversionedFallbackMode activityFallback = workerOptions?.Versioning?.ActivityUnversionedFallback - ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Never; + ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit; return new DurableTaskFactory( registry.ActivitiesByVersion, registry.OrchestratorsByVersion, diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 68f1b71be..4944c3573 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -50,54 +50,70 @@ public enum VersionFailureStrategy } /// - /// Controls when an unversioned task registration is used to serve a versioned request that has no exact - /// match. Only affects task names that have both an unversioned registration and at least one versioned - /// registration; otherwise the dispatch rules are unchanged. + /// Controls how an unversioned task registration is used to serve versioned task requests. Only affects + /// dispatch decisions; orchestration instance acceptance is controlled by . /// /// - /// The matrix below summarizes dispatch for a versioned request: + /// The matrix below summarizes dispatch for a versioned request under each mode: /// /// /// Registration shape for the task name - /// Result with vs. + /// Result with / / /// /// /// Only unversioned registration - /// Both modes dispatch to the unversioned registration. + /// Implicit: unversioned. CatchAll: unversioned. StrictExactOnly: not found. /// /// /// Mixed (versioned + unversioned), exact version match - /// Both modes dispatch to the exact-matching versioned registration. + /// All three modes dispatch to the exact-matching versioned registration. /// /// /// Mixed (versioned + unversioned), no exact version match - /// returns "not found"; dispatches to the unversioned registration. + /// Implicit: not found. CatchAll: unversioned. StrictExactOnly: not found. /// /// /// Only versioned registrations, no exact version match - /// Both modes return "not found" (no unversioned implementation exists). + /// All three modes return "not found" (no unversioned implementation exists). /// /// + /// + /// Unversioned requests (no version specified on the schedule call) always dispatch to the unversioned + /// registration when one exists, regardless of this setting. + /// /// public enum UnversionedFallbackMode { /// - /// Never fall back to an unversioned registration as a catch-all for unmatched versioned requests. This - /// is the default closed-set behavior: once a task name has at least one versioned registration, a - /// request for a version that has no exact match returns "not found" rather than dispatching to the - /// unversioned registration. Unversioned requests are still served by the unversioned registration, and - /// versioned requests still fall back to the unversioned registration when the task name has no - /// versioned registrations at all. + /// Preserve the long-standing implicit fallback: the unversioned registration serves versioned requests + /// only when the task name has no versioned siblings. Once a name has at least one versioned + /// registration, an unmatched versioned request returns "not found" rather than dispatching to the + /// unversioned registration. This is the default and matches behavior prior to per-task versioning. /// - Never = 0, + Implicit = 0, /// - /// Use the unversioned registration as a catch-all when no exact versioned match exists. An exact - /// versioned match still wins; only unmatched versioned requests dispatch to the unversioned - /// registration. Use only when the unversioned implementation is replay-compatible with every version it - /// may receive. + /// Use the unversioned registration as a catch-all when no exact versioned match exists, even when + /// the task name has versioned siblings. An exact versioned match still wins. Use only when the + /// unversioned implementation is replay-compatible with every version it may receive. /// CatchAll = 1, + + /// + /// Require an exact (name, version) registration for every versioned request. Versioned + /// requests for names without an exact registration return "not found" even when an unversioned + /// registration for the same name exists. Use this mode when stale or bogus version values from + /// upstream clients should fail loudly instead of landing on the unversioned registration. + /// + StrictExactOnly = 2, + + /// + /// Obsolete alias for . Prefer — the original + /// Never name was misleading because it did not actually disable the long-standing implicit + /// unversioned-only fallback. Only disables every fallback path. + /// + [Obsolete("Use UnversionedFallbackMode.Implicit instead. The original name was misleading; only StrictExactOnly actually disables every fallback path.")] + Never = Implicit, } /// @@ -297,54 +313,61 @@ public class VersioningOptions public VersionFailureStrategy FailureStrategy { get; set; } = VersionFailureStrategy.Reject; /// - /// Gets or sets whether the unversioned orchestrator registration acts as a catch-all for unmatched + /// Gets or sets how the unversioned orchestrator registration participates in dispatch for /// versioned orchestrator requests. /// /// /// - /// Defaults to . See - /// for the dispatch matrix. + /// Defaults to . See + /// for the dispatch matrix across the three modes. /// /// /// Replay risk is highest on the orchestrator side: orchestrators are deterministic and rehydrate - /// state from history on every replay. Enable only when the unversioned orchestrator implementation - /// is replay-compatible with every version it may receive. Replaying existing histories against an - /// incompatible implementation can cause non-determinism faults or deserialization failures. + /// state from history on every replay. Enable only + /// when the unversioned orchestrator implementation is replay-compatible with every version it may + /// receive. Replaying existing histories against an incompatible implementation can cause + /// non-determinism faults or deserialization failures. + /// eliminates fallback entirely for this side; pair with explicit per-version registrations for every + /// version your callers may schedule. /// /// /// Interaction with other versioning options: /// /// /// Unlike , this setting applies regardless of - /// . The factory-level fallback runs whether or not the pre-dispatch - /// versioning gate is active. + /// . The factory-level fallback decision runs whether or not the + /// pre-dispatch versioning gate is active. /// When is , /// the pre-dispatch versioning gate rejects instance versions that don't equal the worker's - /// configured . Fallback does not bypass the gate, but can still apply post-gate - /// to instances that pass it but lack an exact-version registration. + /// configured . This setting does not bypass the gate, but governs how the + /// factory resolves instances that pass it. Note that with , + /// the worker's configured version must also have an exact registration for the receiving task name; + /// otherwise the factory will reject work items that the strict-mode work-item filter still requests + /// from the backend. /// When is /// , the pre-dispatch versioning gate rejects - /// orchestration versions newer than . Fallback applies only to versions accepted - /// by the gate; newer-than-worker versions are still subject to + /// orchestration versions newer than . This setting governs only how versions + /// accepted by the gate are resolved; newer-than-worker versions are still subject to /// . /// /// - public UnversionedFallbackMode OrchestratorUnversionedFallback { get; set; } = UnversionedFallbackMode.Never; + public UnversionedFallbackMode OrchestratorUnversionedFallback { get; set; } = UnversionedFallbackMode.Implicit; /// - /// Gets or sets whether the unversioned activity registration acts as a catch-all for unmatched - /// versioned activity requests. + /// Gets or sets how the unversioned activity registration participates in dispatch for versioned + /// activity requests. /// /// /// - /// Defaults to . See - /// for the dispatch matrix. + /// Defaults to . See + /// for the dispatch matrix across the three modes. /// /// - /// Activities are stateless and do not replay history, so this setting carries less risk than - /// . The main concern is input contract compatibility: - /// ensure the unversioned activity implementation accepts the input shapes produced by every version - /// of the calling orchestrators that may schedule it. + /// Activities are stateless and do not replay history, so + /// carries less risk than the orchestrator equivalent. The main concern is input contract + /// compatibility: ensure the unversioned activity implementation accepts the input shapes produced by + /// every version of the calling orchestrators that may schedule it. + /// eliminates fallback entirely for activities. /// /// /// Interaction with other versioning options: @@ -356,7 +379,7 @@ public class VersioningOptions /// activity scheduling versions, so it does not gate activity dispatch. /// /// - public UnversionedFallbackMode ActivityUnversionedFallback { get; set; } = UnversionedFallbackMode.Never; + public UnversionedFallbackMode ActivityUnversionedFallback { get; set; } = UnversionedFallbackMode.Implicit; } /// diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index b8b409c35..94cbe0aae 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -45,31 +45,32 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict ? [workerOptions.Versioning.Version ?? string.Empty] : null; - bool useOrchestratorUnversionedFallback = + DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorFallbackMode = workerOptions?.Versioning?.OrchestratorUnversionedFallback - == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; - bool useActivityUnversionedFallback = + ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit; + DurableTaskWorkerOptions.UnversionedFallbackMode activityFallbackMode = workerOptions?.Versioning?.ActivityUnversionedFallback - == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll; + ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit; // Orchestration filters group registrations by logical name and emit the concrete distinct // version set actually registered (treating null/unversioned as ""). Strict mode overrides - // this with the single configured worker version. When the factory can resolve unknown - // versions via an unversioned registration (unversioned-only names, or mixed names with - // opt-in unversioned fallback), we emit an empty version list — the filter wildcard — so the - // backend can deliver versioned work items the factory can handle. Otherwise, emitting the - // concrete version set prevents the backend from streaming work items the worker would then - // reject after the fact. + // this with the single configured worker version. When the factory can resolve unmatched + // versions via the unversioned registration (unversioned-only names under Implicit, or any + // name with an unversioned registration under CatchAll), we emit an empty version list — the + // filter wildcard — so the backend delivers versioned work items the factory can handle. + // Under StrictExactOnly the factory rejects unmatched versioned requests for every name, + // including unversioned-only names, so the filter must emit the concrete version set instead + // of widening. // // Orchestrator and activity fallback are configured independently, so each filter set - // consults its own flag. + // consults its own mode. List orchestrationFilters = registry.OrchestratorsByVersion .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) .Select(group => { IReadOnlyList versions = strictWorkerVersions - ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useOrchestratorUnversionedFallback); + ?? GetFilterVersions(group.Select(entry => entry.Key.Version), orchestratorFallbackMode); return new OrchestrationFilter { @@ -85,7 +86,7 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable { IReadOnlyList versions = strictWorkerVersions - ?? GetFilterVersions(group.Select(entry => entry.Key.Version), useActivityUnversionedFallback); + ?? GetFilterVersions(group.Select(entry => entry.Key.Version), activityFallbackMode); return new ActivityFilter { @@ -106,7 +107,9 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable }).ToList(), }; - static IReadOnlyList GetFilterVersions(IEnumerable versions, bool useUnversionedFallback) + static IReadOnlyList GetFilterVersions( + IEnumerable versions, + DurableTaskWorkerOptions.UnversionedFallbackMode mode) { // Normalize null to "" so an unversioned registration appears consistently. string[] normalized = versions @@ -115,12 +118,29 @@ static IReadOnlyList GetFilterVersions(IEnumerable versions, bo .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) .ToArray(); - // Unversioned-only: emit the wildcard match-all (empty list) so the backend can deliver - // versioned work items that the factory will resolve via unversioned fallback. Without - // this, callers asking for a specific version would be filtered out at the backend even - // though the worker can handle them. - if ((normalized.Length == 1 && normalized[0].Length == 0) - || (useUnversionedFallback && normalized.Contains(string.Empty, StringComparer.OrdinalIgnoreCase))) + // StrictExactOnly disables every fallback path, including the long-standing implicit + // unversioned-only fallback. Emit the concrete registered version set so the backend + // does not deliver versioned work items the factory will reject after the fact. + if (mode == DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly) + { + return normalized; + } + + // Otherwise, widen to a wildcard when the factory can actually resolve unmatched versions: + // - Implicit: only when the registry has no versioned siblings for this name (i.e. + // normalized is exactly [""]). + // - CatchAll: whenever the registry has an unversioned registration for this name. + bool hasUnversionedRegistration = + normalized.Contains(string.Empty, StringComparer.OrdinalIgnoreCase); + bool implicitWildcard = + mode == DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit + && normalized.Length == 1 + && normalized[0].Length == 0; + bool catchAllWildcard = + mode == DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll + && hasUnversionedRegistration; + + if (implicitWildcard || catchAllWildcard) { return []; } diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 8ad60c0f5..ae95d40ec 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -568,6 +568,107 @@ public void WorkItemFilters_UnversionedFallbackWithVersioningStrict_UsesConfigur actual.Activities[0].Versions.Should().BeEquivalentTo(["1.0"]); } + [Fact] + public void WorkItemFilters_StrictExactOnlyForOrchestrators_DoesNotWildcardUnversionedOnly() + { + // Arrange — only the unversioned orchestrator is registered. Under Implicit (default), the + // filter would widen to wildcard [] because the factory resolves unmatched versions via the + // implicit fallback. Under StrictExactOnly the factory rejects those requests, so the filter + // MUST emit the concrete [""] version list to prevent the backend from delivering versioned + // work items the worker will reject after the fact. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo([string.Empty]); + } + + [Fact] + public void WorkItemFilters_StrictExactOnlyForActivities_DoesNotWildcardUnversionedOnly() + { + // Arrange — symmetric activity-side coverage for the StrictExactOnly filter behavior. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddActivity()); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Activities.Should().ContainSingle(); + actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo([string.Empty]); + } + + [Fact] + public void WorkItemFilters_StrictMatchOverridesStrictExactOnly_KnownLimitation() + { + // Arrange — pathological config: MatchStrategy=Strict with a worker Version, combined with + // StrictExactOnly, against an unversioned-only registration. The pre-existing strict override + // emits the worker's Version (best-effort assumption that the user has registered it). Under + // StrictExactOnly with no exact match, the factory will reject those work items. The filter + // still emits the worker version — captured here as a known limitation so the behavior is + // tracked, not silently changed. The per-property remarks document this gap; a proper fix + // would require per-name dispatch-capability analysis and is out of scope for this PR. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1.0", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); + } + [Fact] public void WorkItemFilters_DefaultEmptyRegistry_ProducesEmptyFilters() { diff --git a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs index 16b654de8..007d31c48 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryActivityVersioningTests.cs @@ -223,6 +223,91 @@ public void TryCreateActivity_WithActivityFallback_LogsDispatchAtDebug() && log.Message.Contains("unversioned", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void TryCreateActivity_StrictExactOnlyWithOnlyUnversionedRegistration_RejectsVersionedRequest() + { + // Arrange — only the unversioned registration exists. Under Implicit, a versioned request would + // resolve to it. StrictExactOnly disables that path: the request must fail. + DurableTaskRegistry registry = new(); + registry.AddActivity(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v1"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeFalse(); + activity.Should().BeNull(); + } + + [Fact] + public void TryCreateActivity_StrictExactOnlyWithOnlyUnversionedRegistration_AcceptsUnversionedRequest() + { + // Arrange — exact-match path for unversioned requests is preserved. + DurableTaskRegistry registry = new(); + registry.AddActivity(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = factory.TryCreateActivity( + new TaskName("InvoiceActivity"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + + [Fact] + public void TryCreateActivity_OrchestratorStrictExactOnly_DoesNotAffectActivityFallback() + { + // Arrange — asymmetric explicit modes: orchestrator side StrictExactOnly, activity side CatchAll. + // Activity registry is mixed ([X] + [X v=1]). Versioned request for v9 must fall back to the + // unversioned activity per CatchAll on the activity side; the orchestrator-side flag must NOT leak. + DurableTaskRegistry registry = new(); + registry.AddActivity(); + registry.AddActivity(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateActivity( + new TaskName("InvoiceActivity"), + new TaskVersion("v9"), + Mock.Of(), + out ITaskActivity? activity); + + // Assert + found.Should().BeTrue(); + activity.Should().BeOfType(); + } + [DurableTask("InvoiceActivity", Version = "v1")] sealed class InvoiceActivityV1 : TaskActivity { diff --git a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs index 610a5f496..8ad0e15a6 100644 --- a/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs +++ b/test/Worker/Core.Tests/DurableTaskFactoryVersioningTests.cs @@ -228,6 +228,92 @@ public void TryCreateOrchestrator_WithOnlyUnversionedRegistration_FallsBackForVe orchestrator.Should().BeOfType(); } + [Fact] + public void TryCreateOrchestrator_StrictExactOnlyWithOnlyUnversionedRegistration_RejectsVersionedRequest() + { + // Arrange — only the unversioned registration exists. Under Implicit, a versioned request would + // resolve to it. StrictExactOnly disables that path: the request must fail. + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v1"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeFalse(); + orchestrator.Should().BeNull(); + } + + [Fact] + public void TryCreateOrchestrator_StrictExactOnlyWithOnlyUnversionedRegistration_AcceptsUnversionedRequest() + { + // Arrange — StrictExactOnly disables FALLBACK for versioned requests, not the exact-match path + // for unversioned requests. An unversioned request to an unversioned registration must still + // dispatch (it is an exact match in the empty-version key). + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = factory.TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeTrue(); + orchestrator.Should().BeOfType(); + } + + [Fact] + public void TryCreateOrchestrator_StrictExactOnlyWithMixedRegistrations_RejectsUnmatchedVersion() + { + // Arrange — same registry shape as the "no fallback for unknown version" test, but explicitly + // under StrictExactOnly. Behavior should match Implicit for this shape (both reject), confirming + // StrictExactOnly is a strict generalization of Implicit on mixed names. + DurableTaskRegistry registry = new(); + registry.AddOrchestrator(); + registry.AddOrchestrator(); + DurableTaskWorkerOptions workerOptions = new() + { + Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }, + }; + IDurableTaskFactory factory = registry.BuildFactory(workerOptions); + + // Act + bool found = ((IVersionedTaskFactory)factory).TryCreateOrchestrator( + new TaskName("InvoiceWorkflow"), + new TaskVersion("v9"), + Mock.Of(), + out ITaskOrchestrator? orchestrator); + + // Assert + found.Should().BeFalse(); + orchestrator.Should().BeNull(); + } + [Fact] public void PublicTryCreateOrchestrator_UsesUnversionedRegistrationOnly() { From 1b32e86d3b5e56e9443cad3f0e33af79522ee514 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 13:43:24 -0700 Subject: [PATCH 12/15] Update sample README key takeaways for the three-mode design Add explicit bullets describing Implicit / CatchAll / StrictExactOnly so readers seeing the sample understand the full mode space, not just the CatchAll one this sample exercises. Tighten the UseWorkItemFilters note to reflect the new per-mode filter widening (Implicit and CatchAll widen for the relevant shapes; StrictExactOnly emits the concrete version set). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/UnversionedFallbackSample/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/samples/UnversionedFallbackSample/README.md b/samples/UnversionedFallbackSample/README.md index 060e87008..9eb5b92f7 100644 --- a/samples/UnversionedFallbackSample/README.md +++ b/samples/UnversionedFallbackSample/README.md @@ -67,10 +67,13 @@ 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. -- 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 fallback if you are unsure. -- Unversioned fallback is opt-in. Without `CatchAll` on the corresponding side, a mixed unversioned plus versioned registration remains a closed set and unknown versions fail rather than falling back. -- Use orchestrator fallback 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 by allowing unmatched versions for logical names that have an unversioned catch-all registration on the enabled side. +- 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 From 8855022c0547247515c1e361fe0bc927a632c4db Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Fri, 22 May 2026 14:44:15 -0700 Subject: [PATCH 13/15] Remove premature [Obsolete] Never alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added an [Obsolete] Never = Implicit alias on the rubber-duck's source-compat reasoning. That reasoning doesn't apply here: the Never name only ever existed on the unmerged unversioned-fallback branch, never in a shipped NuGet, so there is no external consumer to protect. Keeping the alias would permanently encode a pre-release name and ship two names for the same value from day one, both of which make the public surface harder to read for every future user. The remaining commit messages and the draft reply to Chris still describe the rename Never -> Implicit accurately — Never simply never became a public symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskWorkerOptions.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 4944c3573..9d17767e5 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -106,14 +106,6 @@ public enum UnversionedFallbackMode /// upstream clients should fail loudly instead of landing on the unversioned registration. /// StrictExactOnly = 2, - - /// - /// Obsolete alias for . Prefer — the original - /// Never name was misleading because it did not actually disable the long-standing implicit - /// unversioned-only fallback. Only disables every fallback path. - /// - [Obsolete("Use UnversionedFallbackMode.Implicit instead. The original name was misleading; only StrictExactOnly actually disables every fallback path.")] - Never = Implicit, } /// From 457e5d6105c6ee2349de0d3f954187d66dd60e7a Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Tue, 26 May 2026 09:22:53 -0700 Subject: [PATCH 14/15] Drop nullable annotation from XML doc cref signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XML doc signatures address overloads by parameter types without nullability annotations; '?' inside a cref signature can fail to resolve depending on compiler/SDK version (CS1574/CS1584). The actual parameter on UseWorkItemFilters(IDurableTaskWorkerBuilder, DurableTaskWorkerWorkItemFilters?) remains nullable in source — only the cref reference is normalized. Addresses Copilot review feedback at #discussion_r3291122794. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 94cbe0aae..4357c0c49 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -9,7 +9,7 @@ namespace Microsoft.DurableTask.Worker; /// the worker will process all work items. To opt-in to work item filtering, call /// for the /// auto-generated filters from the worker's , or -/// +/// /// to supply explicit filters. /// public class DurableTaskWorkerWorkItemFilters From baab66091aabee5f2f251a18770a7a6dae9a8e3a Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal <17064840+torosent@users.noreply.github.com> Date: Tue, 26 May 2026 10:36:48 -0700 Subject: [PATCH 15/15] Filter advertises strict worker version per name only when factory can serve it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the blanket strict-mode override in DurableTaskWorkerWorkItemFilters.FromDurableTaskRegistry with a per-name dispatch-capability check. Under MatchStrategy.Strict, the SDK now advertises [workerVersion] for a name only if the factory can actually serve workerVersion for that name under the configured fallback mode; otherwise the name is omitted from the filter entirely. The new CanServeStrictVersion helper mirrors DurableTaskFactory.ShouldUseUnversionedFallback so the filter agrees with the factory's dispatch decision: - exact (name, V) match: serve, regardless of mode - else fallback path: CatchAll: serve when an unversioned registration exists Implicit: serve when unversioned AND no versioned siblings StrictExactOnly: never Behavior change for MatchStrategy.Strict configurations ------------------------------------------------------- This is a bug fix with an observable behavior change for misconfigured or partially-registered strict workers: Today (on main, v1.24.2): For Strict + V, every registered name is advertised as (name, [V]). The backend may deliver work the worker rejects pre-dispatch (or at factory lookup) because (name, V) isn't actually serviceable. After this fix: Names the worker cannot serve under V are omitted. The backend stops dispatching to that worker for those names. Other workers that can serve them continue to receive the work. If no connected worker advertises a name, items for that name remain queued until one does — instead of being failed by an incompatible worker. This is strictly less wasted dispatch for correctly-configured deployments. Misconfigured single-worker deployments now manifest as "work doesn't make progress" instead of "worker keeps rejecting," which is the more honest signal for a routing-capability mismatch. Also fixes a pre-existing latent gap in Strict + Implicit (mixed unversioned + versioned without exact match for V): today's filter advertises V; the gate and factory both reject. After the fix, the name is omitted because the factory can't serve V under Implicit fallback on a mixed name. Tests ----- - Replaced WorkItemFilters_MixedRegistrationsWithVersioningStrict_UseConfiguredWorkerVersion with WorkItemFilters_MixedRegistrationsWithVersioningStrict_OmitsNamesWithoutResolvableVersion (same scenario, corrected assertion). - Deleted WorkItemFilters_StrictMatchOverridesStrictExactOnly_KnownLimitation (limitation is now fixed). - Deleted WorkItemFilters_UnversionedFallbackWithVersioningStrict_UsesConfiguredWorkerVersion (its assertion is now exactly covered by the new WorkItemFilters_MixedRegistrationsWithVersioningStrictAndCatchAll_AdvertisesWorkerVersion). - Added five positive tests covering: - Mixed + Strict + Implicit -> omit - Mixed + Strict + exact-match -> advertise - Mixed + Strict + CatchAll -> advertise - VersionedOnly + Strict + StrictExactOnly -> omit - UnversionedOnly + Strict + StrictExactOnly -> omit - Added one asymmetric-fallback test for Strict (orchestrator CatchAll, activity default Implicit) to guard against side-mode crosswiring. Removed the now-stale 'known limitation' paragraph from OrchestratorUnversionedFallback. Added a positive remark on the parameterless UseWorkItemFilters() overload describing how generated filters honor versioning capability. 155 + 1 = 156 Worker.Tests pass locally; 136 Worker.Grpc.Tests pass locally. Rubber-duck reviewed before commit; asymmetric strict test and UseWorkItemFilters() remark added based on that feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DurableTaskWorkerBuilderExtensions.cs | 9 + src/Worker/Core/DurableTaskWorkerOptions.cs | 5 +- .../Core/DurableTaskWorkerWorkItemFilters.cs | 134 ++++++--- .../UseWorkItemFiltersTests.cs | 283 +++++++++++++----- 4 files changed, 305 insertions(+), 126 deletions(-) diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index ac1b058f9..d7689dc63 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -224,6 +224,15 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork /// Only use this method when all task types referenced by orchestrations are guaranteed to be /// registered with at least one connected worker. /// + /// + /// Generated filters honor the worker's versioning configuration. Names that the worker cannot + /// serve under the current + /// and settings — for example, a + /// strict-versioned worker whose configured + /// has no exact registration and no enabled fallback path — are omitted from the generated filter + /// so the backend does not dispatch work items the worker would reject. If no connected worker + /// advertises a name, work items for that name remain undispatched until one does. + /// /// public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWorkerBuilder builder) { diff --git a/src/Worker/Core/DurableTaskWorkerOptions.cs b/src/Worker/Core/DurableTaskWorkerOptions.cs index 9d17767e5..b2d9eb864 100644 --- a/src/Worker/Core/DurableTaskWorkerOptions.cs +++ b/src/Worker/Core/DurableTaskWorkerOptions.cs @@ -332,10 +332,7 @@ public class VersioningOptions /// When is , /// the pre-dispatch versioning gate rejects instance versions that don't equal the worker's /// configured . This setting does not bypass the gate, but governs how the - /// factory resolves instances that pass it. Note that with , - /// the worker's configured version must also have an exact registration for the receiving task name; - /// otherwise the factory will reject work items that the strict-mode work-item filter still requests - /// from the backend. + /// factory resolves instances that pass it. /// When is /// , the pre-dispatch versioning gate rejects /// orchestration versions newer than . This setting governs only how versions diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 4357c0c49..92057ce5c 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -38,12 +38,14 @@ public class DurableTaskWorkerWorkItemFilters internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(DurableTaskRegistry registry, DurableTaskWorkerOptions? workerOptions) { // Under MatchStrategy.Strict the worker accepts only instances whose version matches the - // worker's configured Version exactly — including the empty/unversioned case. The filter must - // narrow each name's version list to that single value (treating null as empty) so the backend - // does not stream work items the worker will then reject after the fact. - IReadOnlyList? strictWorkerVersions = + // worker's configured Version exactly (including the empty/unversioned case). The filter must + // then advertise that single value for each name the worker can actually serve under that + // version — and omit names the worker would reject either at the pre-dispatch gate or in the + // factory. The strict version is captured here so per-name dispatch-capability checks below + // can decide whether to advertise it. + string? strictWorkerVersion = workerOptions?.Versioning?.MatchStrategy == DurableTaskWorkerOptions.VersionMatchStrategy.Strict - ? [workerOptions.Versioning.Version ?? string.Empty] + ? workerOptions.Versioning.Version ?? string.Empty : null; DurableTaskWorkerOptions.UnversionedFallbackMode orchestratorFallbackMode = workerOptions?.Versioning?.OrchestratorUnversionedFallback @@ -53,47 +55,39 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable ?? DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit; // Orchestration filters group registrations by logical name and emit the concrete distinct - // version set actually registered (treating null/unversioned as ""). Strict mode overrides - // this with the single configured worker version. When the factory can resolve unmatched - // versions via the unversioned registration (unversioned-only names under Implicit, or any - // name with an unversioned registration under CatchAll), we emit an empty version list — the - // filter wildcard — so the backend delivers versioned work items the factory can handle. - // Under StrictExactOnly the factory rejects unmatched versioned requests for every name, - // including unversioned-only names, so the filter must emit the concrete version set instead - // of widening. + // version set actually registered (treating null/unversioned as ""). When the factory can + // resolve unmatched versions via the unversioned registration (unversioned-only names under + // Implicit, or any name with an unversioned registration under CatchAll), we emit an empty + // version list — the filter wildcard — so the backend delivers versioned work items the + // factory can handle. Under StrictExactOnly the factory rejects unmatched versioned requests + // for every name, so the filter emits the concrete version set instead of widening. + // + // Under MatchStrategy.Strict the filter narrows to the single configured worker version, but + // only for names the worker can actually serve under that version. Names that have neither an + // exact (name, V) registration nor a fallback-serviceable registration under the configured + // mode are omitted from the filter entirely so the backend does not stream work items the + // worker would then reject after the fact. // // Orchestrator and activity fallback are configured independently, so each filter set // consults its own mode. List orchestrationFilters = registry.OrchestratorsByVersion .GroupBy(orchestration => orchestration.Key.Name, StringComparer.OrdinalIgnoreCase) - .Select(group => - { - IReadOnlyList versions = - strictWorkerVersions - ?? GetFilterVersions(group.Select(entry => entry.Key.Version), orchestratorFallbackMode); - - return new OrchestrationFilter - { - Name = group.Key, - Versions = versions, - }; - }) + .SelectMany(group => BuildFilter( + group.Key, + group.Select(entry => entry.Key.Version), + strictWorkerVersion, + orchestratorFallbackMode, + static (name, versions) => new OrchestrationFilter { Name = name, Versions = versions })) .ToList(); List activityFilters = registry.ActivitiesByVersion .GroupBy(activity => activity.Key.Name, StringComparer.OrdinalIgnoreCase) - .Select(group => - { - IReadOnlyList versions = - strictWorkerVersions - ?? GetFilterVersions(group.Select(entry => entry.Key.Version), activityFallbackMode); - - return new ActivityFilter - { - Name = group.Key, - Versions = versions, - }; - }) + .SelectMany(group => BuildFilter( + group.Key, + group.Select(entry => entry.Key.Version), + strictWorkerVersion, + activityFallbackMode, + static (name, versions) => new ActivityFilter { Name = name, Versions = versions })) .ToList(); return new DurableTaskWorkerWorkItemFilters @@ -107,17 +101,73 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable }).ToList(), }; - static IReadOnlyList GetFilterVersions( - IEnumerable versions, - DurableTaskWorkerOptions.UnversionedFallbackMode mode) + static IEnumerable BuildFilter( + string name, + IEnumerable registeredVersions, + string? strictWorkerVersion, + DurableTaskWorkerOptions.UnversionedFallbackMode mode, + Func, TFilter> create) { - // Normalize null to "" so an unversioned registration appears consistently. - string[] normalized = versions + string[] normalized = NormalizeVersions(registeredVersions); + + if (strictWorkerVersion is not null) + { + // Strict mode: advertise the single worker version, but only for names the worker can + // actually serve under it. Omit names that would always be rejected. + if (CanServeStrictVersion(normalized, strictWorkerVersion, mode)) + { + yield return create(name, [strictWorkerVersion]); + } + + yield break; + } + + yield return create(name, GetFilterVersions(normalized, mode)); + } + + static string[] NormalizeVersions(IEnumerable versions) + => versions .Select(version => version ?? string.Empty) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(version => version, StringComparer.OrdinalIgnoreCase) .ToArray(); + static bool CanServeStrictVersion( + string[] registeredVersions, + string strictVersion, + DurableTaskWorkerOptions.UnversionedFallbackMode mode) + { + // Exact match always wins, regardless of mode. The empty-version case is covered by the + // same check: strictVersion == "" only matches an unversioned registration. + bool hasExact = registeredVersions.Contains(strictVersion, StringComparer.OrdinalIgnoreCase); + if (hasExact) + { + return true; + } + + // No exact match: only fallback can save us. Replicates DurableTaskFactory's + // ShouldUseUnversionedFallback logic so the filter agrees with the factory's dispatch + // decision. + bool hasUnversioned = registeredVersions.Contains(string.Empty, StringComparer.OrdinalIgnoreCase); + if (!hasUnversioned) + { + return false; + } + + bool hasAnyVersioned = registeredVersions.Any(v => v.Length > 0); + return mode switch + { + DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll => true, + DurableTaskWorkerOptions.UnversionedFallbackMode.Implicit => !hasAnyVersioned, + DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly => false, + _ => !hasAnyVersioned, + }; + } + + static IReadOnlyList GetFilterVersions( + string[] normalized, + DurableTaskWorkerOptions.UnversionedFallbackMode mode) + { // StrictExactOnly disables every fallback path, including the long-standing implicit // unversioned-only fallback. Emit the concrete registered version set so the backend // does not deliver versioned work items the factory will reject after the fact. diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index ae95d40ec..cfd872ce8 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -230,9 +230,14 @@ public void WorkItemFilters_DefaultVersionWithVersioningStrict_NarrowsGeneratedF } [Fact] - public void WorkItemFilters_MixedRegistrationsWithVersioningStrict_UseConfiguredWorkerVersion() + public void WorkItemFilters_MixedRegistrationsWithVersioningStrict_OmitsNamesWithoutResolvableVersion() { - // Arrange + // Arrange — name "FilterWorkflow" registers v="" + v="v2"; activity "FilterActivity" registers + // v="" + v="v2". Worker is configured Strict + Version="1.0" with default (Implicit) fallback. + // Under Implicit, the unversioned registration does NOT serve "1.0" because the name has + // versioned siblings. The strict pre-dispatch gate would also reject any version != "1.0". + // Therefore the worker cannot serve "1.0" for either name and both should be omitted from the + // generated filter, so the backend does not deliver work items the worker will reject. ServiceCollection services = new(); services.AddDurableTaskWorker("test", builder => { @@ -260,15 +265,210 @@ public void WorkItemFilters_MixedRegistrationsWithVersioningStrict_UseConfigured provider.GetRequiredService>(); DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + // Assert + actual.Orchestrations.Should().BeEmpty(); + actual.Activities.Should().BeEmpty(); + } + + [Fact] + public void WorkItemFilters_MixedRegistrationsWithVersioningStrictMatchingRegisteredVersion_AdvertisesWorkerVersion() + { + // Arrange — same mixed registry as the previous test, but worker Version="v2" matches an exact + // registration. Both name + (name, v2) resolve to the v2 implementation directly, so the filter + // should advertise ["v2"] for each name. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "v2", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + // Assert actual.Orchestrations.Should().ContainSingle(); actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); - actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["v2"]); actual.Activities.Should().ContainSingle(); actual.Activities[0].Name.Should().Be("FilterActivity"); + actual.Activities[0].Versions.Should().BeEquivalentTo(["v2"]); + } + + [Fact] + public void WorkItemFilters_MixedRegistrationsWithVersioningStrictAndCatchAll_AdvertisesWorkerVersion() + { + // Arrange — same mixed registry; worker Version="1.0" (no exact match) but CatchAll is enabled + // for both sides. The factory will resolve "1.0" via the unversioned registration on both + // sides, so the filter must advertise ["1.0"] for each name (not omit them). + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1.0", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); + actual.Activities.Should().ContainSingle(); actual.Activities[0].Versions.Should().BeEquivalentTo(["1.0"]); } + [Fact] + public void WorkItemFilters_StrictWithAsymmetricFallback_AdvertisesOrchestratorOmitsActivity() + { + // Arrange — strict + asymmetric per-side modes. Orchestrator side has CatchAll, so unmatched + // versioned requests fall back to the unversioned orchestrator and the name is advertised. + // Activity side stays at the default Implicit, where a mixed registry rejects fallback, so the + // activity name must be omitted. This guards against accidentally using one side's mode for + // the other in the strict path. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1.0", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(); + actual.Orchestrations[0].Name.Should().Be("FilterWorkflow"); + actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); + actual.Activities.Should().BeEmpty(); + } + + [Fact] + public void WorkItemFilters_VersionedOnlyRegistrationsWithVersioningStrictAndStrictExactOnly_OmitsNames() + { + // Arrange — registry has only versioned registrations (v="v1" + v="v2") with no unversioned + // entries. Worker is Strict + Version="v3" with StrictExactOnly. No exact match exists for "v3" + // and StrictExactOnly disables all fallback. The names must be omitted. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddActivity(); + }); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "v3", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().BeEmpty(); + actual.Activities.Should().BeEmpty(); + } + + [Fact] + public void WorkItemFilters_UnversionedOnlyRegistrationWithVersioningStrictAndStrictExactOnly_OmitsName() + { + // Arrange — name has only an unversioned registration. Worker is Strict + Version="1.0" with + // StrictExactOnly. There is no exact match for "1.0" (only ""), and StrictExactOnly disables + // the implicit fallback. The name must be omitted from the filter. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.Configure(options => + { + options.Versioning = new DurableTaskWorkerOptions.VersioningOptions + { + Version = "1.0", + MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, + OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, + }; + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().BeEmpty(); + } + [Fact] public void WorkItemFilters_StrictWithEmptyWorkerVersion_NarrowsFilterToUnversioned() { @@ -528,46 +728,6 @@ public void WorkItemFilters_UnversionedFallbackWithMixedActivities_EmitsWildcard actual.Activities[0].Versions.Should().BeEmpty(); } - [Fact] - public void WorkItemFilters_UnversionedFallbackWithVersioningStrict_UsesConfiguredWorkerVersion() - { - // Arrange - ServiceCollection services = new(); - services.AddDurableTaskWorker("test", builder => - { - builder.AddTasks(registry => - { - registry.AddOrchestrator(); - registry.AddOrchestrator(); - registry.AddActivity(); - registry.AddActivity(); - }); - builder.Configure(options => - { - options.Versioning = new DurableTaskWorkerOptions.VersioningOptions - { - Version = "1.0", - MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, - OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, - ActivityUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.CatchAll, - }; - }); - builder.UseWorkItemFilters(); - }); - - // Act - ServiceProvider provider = services.BuildServiceProvider(); - IOptionsMonitor filtersMonitor = - provider.GetRequiredService>(); - DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); - - // Assert - actual.Orchestrations.Should().ContainSingle(); - actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); - actual.Activities.Should().ContainSingle(); - actual.Activities[0].Versions.Should().BeEquivalentTo(["1.0"]); - } - [Fact] public void WorkItemFilters_StrictExactOnlyForOrchestrators_DoesNotWildcardUnversionedOnly() { @@ -632,43 +792,6 @@ public void WorkItemFilters_StrictExactOnlyForActivities_DoesNotWildcardUnversio actual.Activities[0].Versions.Should().BeEquivalentTo([string.Empty]); } - [Fact] - public void WorkItemFilters_StrictMatchOverridesStrictExactOnly_KnownLimitation() - { - // Arrange — pathological config: MatchStrategy=Strict with a worker Version, combined with - // StrictExactOnly, against an unversioned-only registration. The pre-existing strict override - // emits the worker's Version (best-effort assumption that the user has registered it). Under - // StrictExactOnly with no exact match, the factory will reject those work items. The filter - // still emits the worker version — captured here as a known limitation so the behavior is - // tracked, not silently changed. The per-property remarks document this gap; a proper fix - // would require per-name dispatch-capability analysis and is out of scope for this PR. - ServiceCollection services = new(); - services.AddDurableTaskWorker("test", builder => - { - builder.AddTasks(registry => registry.AddOrchestrator()); - builder.Configure(options => - { - options.Versioning = new DurableTaskWorkerOptions.VersioningOptions - { - Version = "1.0", - MatchStrategy = DurableTaskWorkerOptions.VersionMatchStrategy.Strict, - OrchestratorUnversionedFallback = DurableTaskWorkerOptions.UnversionedFallbackMode.StrictExactOnly, - }; - }); - builder.UseWorkItemFilters(); - }); - - // Act - ServiceProvider provider = services.BuildServiceProvider(); - IOptionsMonitor filtersMonitor = - provider.GetRequiredService>(); - DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); - - // Assert - actual.Orchestrations.Should().ContainSingle(); - actual.Orchestrations[0].Versions.Should().BeEquivalentTo(["1.0"]); - } - [Fact] public void WorkItemFilters_DefaultEmptyRegistry_ProducesEmptyFilters() {