From 5e745af711055d55e8eb88b8a7008fa3a2d0d050 Mon Sep 17 00:00:00 2001 From: Yunchu Wang <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 10:50:53 -0700 Subject: [PATCH 1/6] Validate UseWorkItemFilters names against registered tasks at worker build time When customers call `UseWorkItemFilters(filters)` with explicit filters, any filter that references an orchestration, activity, or entity name not registered with the worker now throws `OptionsValidationException` at worker startup instead of silently waiting for work items the worker can never handle. This is wired up automatically through `IValidateOptions` so customers do not need to invoke validation themselves. Changes: - Add `DurableTaskWorkerWorkItemFiltersValidator` (`IValidateOptions<>`). - Register it from `UseWorkItemFilters(builder, filters)` when explicit, non-empty filters are supplied. Validator runs once after every PostConfigure callback so later overwrites are validated against the final state. - Filter-name lookups use `DurableTaskRegistry` dictionary keys (`TaskName`, `OrdinalIgnoreCase`) so case differences and entity-name normalization match the runtime behavior. - Failure message lists every unknown name grouped by Orchestrations / Activities / Entities for one-shot fix-up. - CHANGELOG note + XML doc updated to call out the new behavior. - Update two pre-existing tests that relied on configuring filters without registering the matching tasks; behavior they verify (filter propagation and explicit-overrides-auto-gen) is preserved with valid task registrations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../DurableTaskWorkerBuilderExtensions.cs | 29 +- ...rableTaskWorkerWorkItemFiltersValidator.cs | 105 ++++++ .../UseWorkItemFiltersTests.cs | 337 +++++++++++++++++- ...TaskWorkerWorkItemFiltersExtensionTests.cs | 4 +- 5 files changed, 466 insertions(+), 10 deletions(-) create mode 100644 src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e110f7af..8081c85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- Validate explicit `UseWorkItemFilters(filters)` filter names against the worker's `DurableTaskRegistry`. Filters that reference an orchestration, activity, or entity name not registered with the worker now throw `OptionsValidationException` at worker startup instead of silently waiting for work items that will never arrive. No customer-side validation call is required. ## 1.24.1 - Add retry to grpc calls that failed due to transient errors by @sophiatev ([#714](https://github.com/microsoft/durabletask-dotnet/pull/714)) diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index 972fdcf3..625932bd 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -145,8 +145,18 @@ public static IDurableTaskWorkerBuilder UseOrchestrationFilter(this IDurableTask /// The instance of a to use. /// If null, any previously configured filters will be cleared and filtering will be disabled. /// The same instance, allowing for method chaining. - /// By default, no work item filters are applied and the worker processes all work items. - /// Use this method with explicit filters to enable filtering, or with null to disable filtering. + /// + /// + /// By default, no work item filters are applied and the worker processes all work items. + /// Use this method with explicit filters to enable filtering, or with null to disable filtering. + /// + /// + /// The supplied filter names are validated against the worker's + /// when the worker is built. If any filter references an orchestration, activity, or entity name + /// that is not registered with this worker, an is thrown + /// at startup. This prevents the worker from silently waiting on work items it cannot handle. + /// + /// public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWorkerBuilder builder, DurableTaskWorkerWorkItemFilters? workItemFilters) { Check.NotNull(builder); @@ -170,6 +180,21 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork } }); + // Register a validator that fails fast at worker build time if any filter references a task + // name that isn't registered with this worker. This runs after all PostConfigure callbacks, + // so later overwrites (e.g., a subsequent UseWorkItemFilters call) are validated as the + // final state. Skip registration when nothing was supplied to validate. + if (workItemFilters is not null + && (workItemFilters.Orchestrations.Count > 0 + || workItemFilters.Activities.Count > 0 + || workItemFilters.Entities.Count > 0)) + { + builder.Services.AddSingleton>(sp => + new DurableTaskWorkerWorkItemFiltersValidator( + builder.Name, + sp.GetRequiredService>())); + } + return builder; } diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs new file mode 100644 index 00000000..d4d282f0 --- /dev/null +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text; +using Microsoft.Extensions.Options; + +namespace Microsoft.DurableTask.Worker; + +/// +/// Validates that every name configured on for a +/// specific named worker matches a task registered with that worker's . +/// +/// +/// Registered through +/// when the caller provides explicit filters. The validator runs lazily when +/// is first called for the matching worker name +/// (effectively at worker construction), so callers do not need to invoke validation explicitly. +/// +sealed class DurableTaskWorkerWorkItemFiltersValidator : IValidateOptions +{ + readonly string builderName; + readonly IOptionsMonitor registryMonitor; + + /// + /// Initializes a new instance of the class. + /// + /// The named-options name of the worker whose filters this validator is bound to. + /// The monitor used to resolve the worker's at validation time. + public DurableTaskWorkerWorkItemFiltersValidator( + string builderName, IOptionsMonitor registryMonitor) + { + this.builderName = builderName; + this.registryMonitor = Check.NotNull(registryMonitor); + } + + /// + public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFilters options) + { + // Only validate the named options instance for the worker this validator was registered against. + if (!string.Equals(name, this.builderName, StringComparison.Ordinal)) + { + return ValidateOptionsResult.Skip; + } + + Check.NotNull(options); + + DurableTaskRegistry registry = this.registryMonitor.Get(this.builderName); + + List unknownOrchestrations = FindUnknown( + options.Orchestrations.Select(o => o.Name), n => registry.Orchestrators.ContainsKey(n)); + List unknownActivities = FindUnknown( + options.Activities.Select(a => a.Name), n => registry.Activities.ContainsKey(n)); + List unknownEntities = FindUnknown( + options.Entities.Select(e => e.Name), n => registry.Entities.ContainsKey(n)); + + if (unknownOrchestrations.Count == 0 + && unknownActivities.Count == 0 + && unknownEntities.Count == 0) + { + return ValidateOptionsResult.Success; + } + + StringBuilder sb = new(); + sb.Append("Cannot configure work item filters for worker '").Append(this.builderName) + .Append("': the following filter names do not match any registered task. ") + .Append("Register them on the worker (via AddTasks/AddOrchestrator/AddActivity/AddEntity) ") + .Append("or remove them from the filters."); + AppendCategory(sb, "Orchestrations", unknownOrchestrations); + AppendCategory(sb, "Activities", unknownActivities); + AppendCategory(sb, "Entities", unknownEntities); + + return ValidateOptionsResult.Fail(sb.ToString()); + } + + static List FindUnknown(IEnumerable names, Func isRegistered) + { + List unknown = []; + foreach (string name in names) + { + if (string.IsNullOrEmpty(name)) + { + unknown.Add(""); + continue; + } + + // TaskName equality is OrdinalIgnoreCase, mirroring how registered keys are compared. + if (!isRegistered(name)) + { + unknown.Add(name); + } + } + + return unknown; + } + + static void AppendCategory(StringBuilder sb, string category, List unknown) + { + if (unknown.Count == 0) + { + return; + } + + sb.Append(' ').Append(category).Append(": [").Append(string.Join(", ", unknown)).Append(']'); + } +} diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index adaac30a..bd113371 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -28,11 +28,17 @@ public void UseWorkItemFilters_WithExplicitFilters_RegistersFilters() // Arrange ServiceCollection services = new(); DefaultDurableTaskWorkerBuilder builder = new("test", services); + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddEntity(); + }); DurableTaskWorkerWorkItemFilters filters = new() { - Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "MyOrch", Versions = ["1.0"] }], - Activities = [new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = "MyActivity", Versions = [] }], - Entities = [new DurableTaskWorkerWorkItemFilters.EntityFilter { Name = "myentity" }], + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator), Versions = ["1.0"] }], + Activities = [new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = nameof(TestActivity), Versions = [] }], + Entities = [new DurableTaskWorkerWorkItemFilters.EntityFilter { Name = nameof(TestEntity) }], }; // Act @@ -266,11 +272,13 @@ public void WorkItemFilters_DefaultNoFilters_WhenNoExplicitOptIn() [Fact] public void WorkItemFilters_ExplicitFiltersOverrideAutoGenerated() { - // Arrange + // Arrange - the explicit filter names must reference registered tasks, but the explicit + // filters should still fully replace auto-generated ones (e.g., zero out Activities even + // though TestActivity is registered). ServiceCollection services = new(); DurableTaskWorkerWorkItemFilters customFilters = new() { - Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "CustomOrch", Versions = ["2.0"] }], + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator), Versions = ["2.0"] }], Activities = [], Entities = [], }; @@ -292,7 +300,7 @@ public void WorkItemFilters_ExplicitFiltersOverrideAutoGenerated() DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); // Assert - actual.Orchestrations.Should().ContainSingle(o => o.Name == "CustomOrch" && o.Versions.Contains("2.0")); + actual.Orchestrations.Should().ContainSingle(o => o.Name == nameof(TestOrchestrator) && o.Versions.Contains("2.0")); actual.Activities.Should().BeEmpty(); actual.Entities.Should().BeEmpty(); } @@ -394,6 +402,323 @@ public void WorkItemFilters_NamedBuilders_HaveUniqueDefaultFilters_WhenExplicitl worker1Filters.Should().NotBeSameAs(worker2Filters); } + [Fact] + public void WorkItemFilters_ExplicitOrchestrationFilterNotRegistered_ThrowsOnGet() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "NotRegistered" }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().Throw() + .Which.Failures.Should().Contain(f => f.Contains("Orchestrations: [NotRegistered]")); + } + + [Fact] + public void WorkItemFilters_ExplicitActivityFilterNotRegistered_ThrowsOnGet() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddActivity()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Activities = [new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = "Ghost" }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().Throw() + .Which.Failures.Should().Contain(f => f.Contains("Activities: [Ghost]")); + } + + [Fact] + public void WorkItemFilters_ExplicitEntityFilterNotRegistered_ThrowsOnGet() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddEntity()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Entities = [new DurableTaskWorkerWorkItemFilters.EntityFilter { Name = "missing" }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().Throw() + .Which.Failures.Should().Contain(f => f.Contains("Entities: [missing]")); + } + + [Fact] + public void WorkItemFilters_MultipleUnknownNames_AreAllReported() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddActivity(); + }); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = + [ + new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator) }, + new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "MissingOrch" }, + ], + Activities = + [ + new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = "MissingActivity" }, + ], + Entities = + [ + new DurableTaskWorkerWorkItemFilters.EntityFilter { Name = "missingEntity" }, + ], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().Throw() + .Which.Failures.Should().ContainSingle() + .Which.Should().Match(f => + ((string)f).Contains("Orchestrations: [MissingOrch]") + && ((string)f).Contains("Activities: [MissingActivity]") + && ((string)f).Contains("Entities: [missingEntity]")); + } + + [Fact] + public void WorkItemFilters_AllExplicitFiltersRegistered_DoesNotThrow() + { + // Arrange + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => + { + registry.AddOrchestrator(); + registry.AddActivity(); + registry.AddEntity(); + }); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator) }], + Activities = [new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = nameof(TestActivity) }], + Entities = [new DurableTaskWorkerWorkItemFilters.EntityFilter { Name = nameof(TestEntity) }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void WorkItemFilters_FilterNameDiffersInCase_DoesNotThrow() + { + // Arrange - TaskName equality is OrdinalIgnoreCase, so casing should not matter. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter + { + Name = nameof(TestOrchestrator).ToUpperInvariant(), + }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void WorkItemFilters_InvalidExplicitOverwrittenByAutoGen_DoesNotThrow() + { + // Arrange - the second UseWorkItemFilters() overwrites the invalid explicit filters with + // auto-generated ones. The validator must run against the final state (auto-gen), not the + // intermediate (invalid) state. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "WillBeOverwritten" }], + }); + builder.UseWorkItemFilters(); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().ContainSingle(o => o.Name == nameof(TestOrchestrator)); + } + + [Fact] + public void WorkItemFilters_InvalidExplicitOverwrittenByNull_DoesNotThrow() + { + // Arrange - clearing filters with null must not throw based on the cleared, prior-invalid state. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "Bogus" }], + }); + builder.UseWorkItemFilters(null); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + + // Assert + actual.Orchestrations.Should().BeEmpty(); + actual.Activities.Should().BeEmpty(); + actual.Entities.Should().BeEmpty(); + } + + [Fact] + public void WorkItemFilters_InvalidExplicitOverwrittenByValidExplicit_DoesNotThrow() + { + // Arrange - the last UseWorkItemFilters call wins; validation must reflect the final state. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "Stale" }], + }); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator) }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void WorkItemFilters_NullExplicitFilters_DoNotRegisterValidator() + { + // Arrange - passing null should not register a validator that fires based on registry state. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(_ => { }); + builder.UseWorkItemFilters(null); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void WorkItemFilters_ValidationIsScopedToEachNamedWorker() + { + // Arrange - worker A has invalid explicit filters; worker B is fine. + // Getting B's options should not throw because of A's misconfiguration. + ServiceCollection services = new(); + services.AddDurableTaskWorker("workerA", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "Unknown" }], + }); + }); + services.AddDurableTaskWorker("workerB", builder => + { + builder.AddTasks(registry => registry.AddActivity()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Activities = [new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = nameof(TestActivity) }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + + Action getA = () => filtersMonitor.Get("workerA"); + Action getB = () => filtersMonitor.Get("workerB"); + + // Assert + getA.Should().Throw(); + getB.Should().NotThrow(); + } + sealed class TestOrchestrator : TaskOrchestrator { public override Task RunAsync(TaskOrchestrationContext context, object input) diff --git a/test/Worker/Grpc.Tests/DurableTaskWorkerWorkItemFiltersExtensionTests.cs b/test/Worker/Grpc.Tests/DurableTaskWorkerWorkItemFiltersExtensionTests.cs index e8c45483..aa5449a3 100644 --- a/test/Worker/Grpc.Tests/DurableTaskWorkerWorkItemFiltersExtensionTests.cs +++ b/test/Worker/Grpc.Tests/DurableTaskWorkerWorkItemFiltersExtensionTests.cs @@ -340,7 +340,7 @@ public void WorkerConstruction_ExplicitFilters_FlowToWorker() DurableTaskWorkerWorkItemFilters customFilters = new() { - Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "CustomOrch", Versions = ["2.0"] }], + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator), Versions = ["2.0"] }], Activities = [], Entities = [], }; @@ -367,7 +367,7 @@ public void WorkerConstruction_ExplicitFilters_FlowToWorker() // Assert filters.Should().NotBeNull(); - filters!.Orchestrations.Should().ContainSingle(o => o.Name == "CustomOrch" && o.Versions.Contains("2.0")); + filters!.Orchestrations.Should().ContainSingle(o => o.Name == nameof(TestOrchestrator) && o.Versions.Contains("2.0")); filters.Activities.Should().BeEmpty(); filters.Entities.Should().BeEmpty(); } From 0e71490d610a4994eebeaf1a91d708b570414c48 Mon Sep 17 00:00:00 2001 From: Yunchu Wang <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 12:05:48 -0700 Subject: [PATCH 2/6] Address PR review: idempotent validator registration, explicit TaskName conversion, stronger DI registration test - DurableTaskWorkerBuilderExtensions: Make UseWorkItemFilters(filters) validator registration idempotent per builder name using a private marker class. Repeat calls with explicit non-empty filters no longer register multiple validator singletons (which would otherwise produce duplicated/order-dependent validation failures). - DurableTaskWorkerWorkItemFiltersValidator: Construct TaskName explicitly when probing the registry instead of relying on the implicit string->TaskName conversion at the call site. - UseWorkItemFiltersTests: Strengthen WorkItemFilters_NullExplicitFilters_DoNotRegisterValidator to actually inspect the IServiceCollection for the absence of IValidateOptions registrations. Add WorkItemFilters_RepeatedExplicitCalls_RegisterSingleValidatorPerWorker and WorkItemFilters_RepeatedExplicitInvalidCalls_ReportFailureExactlyOnce to lock in the idempotent-registration contract. --- .../DurableTaskWorkerBuilderExtensions.cs | 37 +++++++++- ...rableTaskWorkerWorkItemFiltersValidator.cs | 4 +- .../UseWorkItemFiltersTests.cs | 68 ++++++++++++++++++- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index 625932bd..3ba60ff1 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -183,12 +183,16 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork // Register a validator that fails fast at worker build time if any filter references a task // name that isn't registered with this worker. This runs after all PostConfigure callbacks, // so later overwrites (e.g., a subsequent UseWorkItemFilters call) are validated as the - // final state. Skip registration when nothing was supplied to validate. + // final state. Skip registration when nothing was supplied to validate, and ensure + // registration is idempotent per builder name so repeated calls do not produce duplicate + // validators (which would otherwise yield order-/call-count-dependent failure messages). if (workItemFilters is not null && (workItemFilters.Orchestrations.Count > 0 || workItemFilters.Activities.Count > 0 - || workItemFilters.Entities.Count > 0)) + || workItemFilters.Entities.Count > 0) + && !HasWorkItemFiltersValidatorMarker(builder.Services, builder.Name)) { + builder.Services.AddSingleton(new WorkItemFiltersValidatorRegistrationMarker(builder.Name)); builder.Services.AddSingleton>(sp => new DurableTaskWorkerWorkItemFiltersValidator( builder.Name, @@ -236,4 +240,33 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork return builder; } + + static bool HasWorkItemFiltersValidatorMarker(IServiceCollection services, string builderName) + { + foreach (ServiceDescriptor descriptor in services) + { + if (descriptor.ImplementationInstance is WorkItemFiltersValidatorRegistrationMarker marker + && string.Equals(marker.BuilderName, builderName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// Marker registered alongside so that + /// repeated calls to + /// for the same builder name do not register multiple validator singletons. + /// + sealed class WorkItemFiltersValidatorRegistrationMarker + { + public WorkItemFiltersValidatorRegistrationMarker(string builderName) + { + this.BuilderName = builderName; + } + + public string BuilderName { get; } + } } diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs index d4d282f0..1e338cf7 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -84,7 +84,9 @@ static List FindUnknown(IEnumerable names, Func } // TaskName equality is OrdinalIgnoreCase, mirroring how registered keys are compared. - if (!isRegistered(name)) + // Construct the TaskName explicitly so the conversion is not dependent on the implicit + // string -> TaskName operator (which could be removed/changed independently). + if (!isRegistered(new TaskName(name))) { unknown.Add(name); } diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index bd113371..35d7d04c 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -679,10 +679,76 @@ public void WorkItemFilters_NullExplicitFilters_DoNotRegisterValidator() provider.GetRequiredService>(); Action act = () => filtersMonitor.Get("test"); - // Assert + // Assert - no validator should have been registered for this options type at all, + // not just that validation happens to pass for empty filters. + services.Should().NotContain( + d => d.ServiceType == typeof(IValidateOptions), + "passing null should not opt the worker into filter-name validation"); act.Should().NotThrow(); } + [Fact] + public void WorkItemFilters_RepeatedExplicitCalls_RegisterSingleValidatorPerWorker() + { + // Arrange - calling UseWorkItemFilters multiple times with explicit non-empty filters + // for the same named worker must register the validator at most once. Otherwise validation + // would run multiple times for the same options instance and produce duplicated/order- + // dependent failure messages. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "First" }], + }); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "Second" }], + }); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator) }], + }); + }); + + // Assert - exactly one validator is registered for this options type, even though + // UseWorkItemFilters was called three times. + services.Where(d => d.ServiceType == typeof(IValidateOptions)) + .Should().ContainSingle(); + } + + [Fact] + public void WorkItemFilters_RepeatedExplicitInvalidCalls_ReportFailureExactlyOnce() + { + // Arrange - even when the final state is invalid, the failure should be reported by a single + // validator, not duplicated once per UseWorkItemFilters call. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "Bogus1" }], + }); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "Bogus2" }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get("test"); + + // Assert - one Failures entry, not two; the final state ("Bogus2") is what fails. + act.Should().Throw() + .Which.Failures.Should().ContainSingle() + .Which.Should().Contain("Orchestrations: [Bogus2]"); + } + [Fact] public void WorkItemFilters_ValidationIsScopedToEachNamedWorker() { From 9714c643f92e10a07120db0ad7b1fa6961f3ea96 Mon Sep 17 00:00:00 2001 From: Yunchu Wang <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 14:44:32 -0700 Subject: [PATCH 3/6] Simplify validator: single global instance keyed on Validate's name parameter Replace the per-builder marker-based registration with TryAddEnumerable, DurableTaskWorkerWorkItemFiltersValidator>(). The framework already passes the named-options name to IValidateOptions.Validate, so a single validator instance can dispatch by that parameter instead of being bound to a captured builderName. TryAddEnumerable de-duplicates by implementation type, eliminating the need for a separately tracked WorkItemFiltersValidatorRegistrationMarker class and the O(N) IServiceCollection scan that went with it. Removes ~100 lines of indirection (validator field, marker class, helper scan, conditional registration block) while preserving every observable behavior. The two now-redundant tests (NullExplicitFilters_DoNotRegisterValidator, RepeatedExplicitCalls_RegisterSingleValidatorPerWorker) are removed because they locked down internal registration mechanics that no longer apply; the user-observable invariants they encoded are still covered by RepeatedExplicitInvalidCalls_ReportFailureExactlyOnce and ValidationIsScopedToEachNamedWorker. --- .../DurableTaskWorkerBuilderExtensions.cs | 57 ++++--------------- ...rableTaskWorkerWorkItemFiltersValidator.cs | 28 ++++----- .../UseWorkItemFiltersTests.cs | 56 ------------------ 3 files changed, 20 insertions(+), 121 deletions(-) diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index 3ba60ff1..5557ccb5 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.DurableTask.Worker.Hosting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using static Microsoft.DurableTask.Worker.DurableTaskWorkerOptions; @@ -180,24 +181,15 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork } }); - // Register a validator that fails fast at worker build time if any filter references a task - // name that isn't registered with this worker. This runs after all PostConfigure callbacks, - // so later overwrites (e.g., a subsequent UseWorkItemFilters call) are validated as the - // final state. Skip registration when nothing was supplied to validate, and ensure - // registration is idempotent per builder name so repeated calls do not produce duplicate - // validators (which would otherwise yield order-/call-count-dependent failure messages). - if (workItemFilters is not null - && (workItemFilters.Orchestrations.Count > 0 - || workItemFilters.Activities.Count > 0 - || workItemFilters.Entities.Count > 0) - && !HasWorkItemFiltersValidatorMarker(builder.Services, builder.Name)) - { - builder.Services.AddSingleton(new WorkItemFiltersValidatorRegistrationMarker(builder.Name)); - builder.Services.AddSingleton>(sp => - new DurableTaskWorkerWorkItemFiltersValidator( - builder.Name, - sp.GetRequiredService>())); - } + // Register a single global validator that fails fast at worker build time if any filter + // references a task name that isn't registered with the corresponding worker. The validator + // dispatches per named-options instance via the 'name' parameter that the Options framework + // passes to IValidateOptions.Validate, so a single registration covers every named worker. + // TryAddEnumerable de-duplicates by implementation type, making repeat calls idempotent + // across all UseWorkItemFilters invocations. + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IValidateOptions, + DurableTaskWorkerWorkItemFiltersValidator>()); return builder; } @@ -240,33 +232,4 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork return builder; } - - static bool HasWorkItemFiltersValidatorMarker(IServiceCollection services, string builderName) - { - foreach (ServiceDescriptor descriptor in services) - { - if (descriptor.ImplementationInstance is WorkItemFiltersValidatorRegistrationMarker marker - && string.Equals(marker.BuilderName, builderName, StringComparison.Ordinal)) - { - return true; - } - } - - return false; - } - - /// - /// Marker registered alongside so that - /// repeated calls to - /// for the same builder name do not register multiple validator singletons. - /// - sealed class WorkItemFiltersValidatorRegistrationMarker - { - public WorkItemFiltersValidatorRegistrationMarker(string builderName) - { - this.BuilderName = builderName; - } - - public string BuilderName { get; } - } } diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs index 1e338cf7..740224ac 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -7,44 +7,36 @@ namespace Microsoft.DurableTask.Worker; /// -/// Validates that every name configured on for a -/// specific named worker matches a task registered with that worker's . +/// Validates that every name configured on matches a +/// task registered with the corresponding named worker's . /// /// -/// Registered through -/// when the caller provides explicit filters. The validator runs lazily when -/// is first called for the matching worker name +/// Registered as a single global via +/// . +/// The Options framework dispatches the named-options name to , +/// which is then used to resolve the matching . Validation runs +/// lazily when is first called for a worker /// (effectively at worker construction), so callers do not need to invoke validation explicitly. /// sealed class DurableTaskWorkerWorkItemFiltersValidator : IValidateOptions { - readonly string builderName; readonly IOptionsMonitor registryMonitor; /// /// Initializes a new instance of the class. /// - /// The named-options name of the worker whose filters this validator is bound to. /// The monitor used to resolve the worker's at validation time. - public DurableTaskWorkerWorkItemFiltersValidator( - string builderName, IOptionsMonitor registryMonitor) + public DurableTaskWorkerWorkItemFiltersValidator(IOptionsMonitor registryMonitor) { - this.builderName = builderName; this.registryMonitor = Check.NotNull(registryMonitor); } /// public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFilters options) { - // Only validate the named options instance for the worker this validator was registered against. - if (!string.Equals(name, this.builderName, StringComparison.Ordinal)) - { - return ValidateOptionsResult.Skip; - } - Check.NotNull(options); - DurableTaskRegistry registry = this.registryMonitor.Get(this.builderName); + DurableTaskRegistry registry = this.registryMonitor.Get(name); List unknownOrchestrations = FindUnknown( options.Orchestrations.Select(o => o.Name), n => registry.Orchestrators.ContainsKey(n)); @@ -61,7 +53,7 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil } StringBuilder sb = new(); - sb.Append("Cannot configure work item filters for worker '").Append(this.builderName) + sb.Append("Cannot configure work item filters for worker '").Append(name) .Append("': the following filter names do not match any registered task. ") .Append("Register them on the worker (via AddTasks/AddOrchestrator/AddActivity/AddEntity) ") .Append("or remove them from the filters."); diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 35d7d04c..4c9795fa 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -662,62 +662,6 @@ public void WorkItemFilters_InvalidExplicitOverwrittenByValidExplicit_DoesNotThr act.Should().NotThrow(); } - [Fact] - public void WorkItemFilters_NullExplicitFilters_DoNotRegisterValidator() - { - // Arrange - passing null should not register a validator that fires based on registry state. - ServiceCollection services = new(); - services.AddDurableTaskWorker("test", builder => - { - builder.AddTasks(_ => { }); - builder.UseWorkItemFilters(null); - }); - - // Act - ServiceProvider provider = services.BuildServiceProvider(); - IOptionsMonitor filtersMonitor = - provider.GetRequiredService>(); - Action act = () => filtersMonitor.Get("test"); - - // Assert - no validator should have been registered for this options type at all, - // not just that validation happens to pass for empty filters. - services.Should().NotContain( - d => d.ServiceType == typeof(IValidateOptions), - "passing null should not opt the worker into filter-name validation"); - act.Should().NotThrow(); - } - - [Fact] - public void WorkItemFilters_RepeatedExplicitCalls_RegisterSingleValidatorPerWorker() - { - // Arrange - calling UseWorkItemFilters multiple times with explicit non-empty filters - // for the same named worker must register the validator at most once. Otherwise validation - // would run multiple times for the same options instance and produce duplicated/order- - // dependent failure messages. - ServiceCollection services = new(); - services.AddDurableTaskWorker("test", builder => - { - builder.AddTasks(registry => registry.AddOrchestrator()); - builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters - { - Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "First" }], - }); - builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters - { - Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "Second" }], - }); - builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters - { - Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = nameof(TestOrchestrator) }], - }); - }); - - // Assert - exactly one validator is registered for this options type, even though - // UseWorkItemFilters was called three times. - services.Where(d => d.ServiceType == typeof(IValidateOptions)) - .Should().ContainSingle(); - } - [Fact] public void WorkItemFilters_RepeatedExplicitInvalidCalls_ReportFailureExactlyOnce() { From 5c401a2f58abe8ac7f2121806163d06afb47f617 Mon Sep 17 00:00:00 2001 From: Yunchu Wang <223556219+Copilot@users.noreply.github.com> Date: Mon, 4 May 2026 15:27:19 -0700 Subject: [PATCH 4/6] Address PR review: gate validator registration and improve default-name display 1. Restore the registration gate in UseWorkItemFilters(builder, filters): only call TryAddEnumerable when filters is non-null AND has at least one orchestration/activity/entity entry. Passing null (opt-out) or an empty filter set has nothing to validate, so registering the validator would unnecessarily pull IOptionsMonitor into the resolution chain and contradict the documented intent. 2. In DurableTaskWorkerWorkItemFiltersValidator, normalize the worker name shown in the failure message: when 'name' is null or string.Empty (the default-name worker), display '' instead of an empty quoted name. The actual 'name' is still used to resolve the registry. Adds three lock-in tests: NullExplicitFilters_DoNotRegisterValidator, EmptyExplicitFilters_DoNotRegisterValidator, and DefaultNamedWorkerInvalidFilter_FailureMessageUsesDefaultPlaceholder. --- .../DurableTaskWorkerBuilderExtensions.cs | 16 ++++- ...rableTaskWorkerWorkItemFiltersValidator.cs | 3 +- .../UseWorkItemFiltersTests.cs | 65 +++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index 5557ccb5..a9274078 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -187,9 +187,19 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork // passes to IValidateOptions.Validate, so a single registration covers every named worker. // TryAddEnumerable de-duplicates by implementation type, making repeat calls idempotent // across all UseWorkItemFilters invocations. - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< - IValidateOptions, - DurableTaskWorkerWorkItemFiltersValidator>()); + // + // Skip registration when the caller passed null (opt-out) or an empty filter set: there is + // nothing to validate, and registering would otherwise pull IOptionsMonitor + // into the resolution chain for workers that have explicitly opted out of filtering. + if (workItemFilters is not null + && (workItemFilters.Orchestrations.Count > 0 + || workItemFilters.Activities.Count > 0 + || workItemFilters.Entities.Count > 0)) + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< + IValidateOptions, + DurableTaskWorkerWorkItemFiltersValidator>()); + } return builder; } diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs index 740224ac..07858d43 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -53,7 +53,8 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil } StringBuilder sb = new(); - sb.Append("Cannot configure work item filters for worker '").Append(name) + string displayName = string.IsNullOrEmpty(name) ? "" : name!; + sb.Append("Cannot configure work item filters for worker '").Append(displayName) .Append("': the following filter names do not match any registered task. ") .Append("Register them on the worker (via AddTasks/AddOrchestrator/AddActivity/AddEntity) ") .Append("or remove them from the filters."); diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 4c9795fa..91ac93df 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -662,6 +662,71 @@ public void WorkItemFilters_InvalidExplicitOverwrittenByValidExplicit_DoesNotThr act.Should().NotThrow(); } + [Fact] + public void WorkItemFilters_NullExplicitFilters_DoNotRegisterValidator() + { + // Arrange - passing null is an explicit opt-out; no validator should be registered because + // there is nothing to validate (and the worker should not pull DurableTaskRegistry into the + // resolution chain when filtering is disabled). + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(_ => { }); + builder.UseWorkItemFilters(null); + }); + + // Assert - no validator registered. + services.Should().NotContain( + d => d.ServiceType == typeof(IValidateOptions), + "passing null should not opt the worker into filter-name validation"); + } + + [Fact] + public void WorkItemFilters_EmptyExplicitFilters_DoNotRegisterValidator() + { + // Arrange - an explicit but empty filter set has nothing to validate, so the validator + // should not be registered. + ServiceCollection services = new(); + services.AddDurableTaskWorker("test", builder => + { + builder.AddTasks(_ => { }); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters()); + }); + + // Assert + services.Should().NotContain( + d => d.ServiceType == typeof(IValidateOptions), + "empty filters have nothing to validate"); + } + + [Fact] + public void WorkItemFilters_DefaultNamedWorkerInvalidFilter_FailureMessageUsesDefaultPlaceholder() + { + // Arrange - default-name worker (Options.DefaultName == string.Empty) with an invalid filter. + // The failure message should display "" instead of an empty quoted name so the + // misconfigured worker is identifiable. + ServiceCollection services = new(); + services.AddDurableTaskWorker(builder => + { + builder.AddTasks(registry => registry.AddOrchestrator()); + builder.UseWorkItemFilters(new DurableTaskWorkerWorkItemFilters + { + Orchestrations = [new DurableTaskWorkerWorkItemFilters.OrchestrationFilter { Name = "DoesNotExist" }], + }); + }); + + // Act + ServiceProvider provider = services.BuildServiceProvider(); + IOptionsMonitor filtersMonitor = + provider.GetRequiredService>(); + Action act = () => filtersMonitor.Get(Options.DefaultName); + + // Assert + act.Should().Throw() + .Which.Failures.Should().ContainSingle() + .Which.Should().Contain("worker ''"); + } + [Fact] public void WorkItemFilters_RepeatedExplicitInvalidCalls_ReportFailureExactlyOnce() { From 5447f985ac5f6b80f0492d4d5b7aafa5c03ade50 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 6 May 2026 10:16:30 -0700 Subject: [PATCH 5/6] Address remaining PR review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../DependencyInjection/UseWorkItemFiltersTests.cs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8081c85d..7fa3f70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## Unreleased -- Validate explicit `UseWorkItemFilters(filters)` filter names against the worker's `DurableTaskRegistry`. Filters that reference an orchestration, activity, or entity name not registered with the worker now throw `OptionsValidationException` at worker startup instead of silently waiting for work items that will never arrive. No customer-side validation call is required. +- Validate explicit `UseWorkItemFilters(filters)` filter names against the worker's `DurableTaskRegistry`. Filters that reference an orchestration, activity, or entity name not registered with the worker now throw `OptionsValidationException` at worker startup instead of silently waiting for work items that will never arrive. No customer-side validation call is required. ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) ## 1.24.1 - Add retry to grpc calls that failed due to transient errors by @sophiatev ([#714](https://github.com/microsoft/durabletask-dotnet/pull/714)) diff --git a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs index 91ac93df..98c7a370 100644 --- a/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs +++ b/test/Worker/Core.Tests/DependencyInjection/UseWorkItemFiltersTests.cs @@ -626,9 +626,11 @@ public void WorkItemFilters_InvalidExplicitOverwrittenByNull_DoesNotThrow() ServiceProvider provider = services.BuildServiceProvider(); IOptionsMonitor filtersMonitor = provider.GetRequiredService>(); - DurableTaskWorkerWorkItemFilters actual = filtersMonitor.Get("test"); + DurableTaskWorkerWorkItemFilters actual = null!; + Action act = () => actual = filtersMonitor.Get("test"); // Assert + act.Should().NotThrow(); actual.Orchestrations.Should().BeEmpty(); actual.Activities.Should().BeEmpty(); actual.Entities.Should().BeEmpty(); @@ -813,4 +815,4 @@ public override Task RunAsync(TaskActivityContext context, object input) sealed class TestEntity : TaskEntity { } -} \ No newline at end of file +} From 0d584582b99f102773fce916d4cb18dfe9bb9420 Mon Sep 17 00:00:00 2001 From: Yunchu Wang <223556219+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 12:13:36 -0700 Subject: [PATCH 6/6] Validator: return Skip for empty filters in DurableTaskWorkerWorkItemFiltersValidator.Validate (review feedback). --- .../DurableTaskWorkerWorkItemFiltersValidator.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs index 07858d43..c8eefb12 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -36,6 +36,17 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil { Check.NotNull(options); + // The validator is registered globally, so the Options framework dispatches every named + // worker's filter options through it -- including workers that never opted into filtering + // and therefore have no filter entries to validate. Skip those cases so the validator only + // reports a verdict for workers that actually configured filters. + if (options.Orchestrations.Count == 0 + && options.Activities.Count == 0 + && options.Entities.Count == 0) + { + return ValidateOptionsResult.Skip; + } + DurableTaskRegistry registry = this.registryMonitor.Get(name); List unknownOrchestrations = FindUnknown(