diff --git a/src/Temporalio.Extensions.Hosting/ITemporalWorkerServiceOptionsBuilder.cs b/src/Temporalio.Extensions.Hosting/ITemporalWorkerServiceOptionsBuilder.cs index 512e8417..15e43cbf 100644 --- a/src/Temporalio.Extensions.Hosting/ITemporalWorkerServiceOptionsBuilder.cs +++ b/src/Temporalio.Extensions.Hosting/ITemporalWorkerServiceOptionsBuilder.cs @@ -14,6 +14,11 @@ public interface ITemporalWorkerServiceOptionsBuilder /// string TaskQueue { get; } + /// + /// Gets the build ID for this worker service. If unset, versioning is disabled. + /// + string? BuildId { get; } + /// /// Gets the service collection being configured. /// diff --git a/src/Temporalio.Extensions.Hosting/TemporalHostingServiceCollectionExtensions.cs b/src/Temporalio.Extensions.Hosting/TemporalHostingServiceCollectionExtensions.cs index e064ba7b..91515d84 100644 --- a/src/Temporalio.Extensions.Hosting/TemporalHostingServiceCollectionExtensions.cs +++ b/src/Temporalio.Extensions.Hosting/TemporalHostingServiceCollectionExtensions.cs @@ -17,9 +17,9 @@ public static class TemporalHostingServiceCollectionExtensions /// Add a hosted Temporal worker service as a that contains /// its own client that connects with the given target and namespace. To use an injected /// , use - /// . The worker service - /// will be registered as a singleton. The result is an options builder that can be used to - /// configure the service. + /// . The worker + /// service will be registered as a singleton. The result is an options builder that can be + /// used to configure the service. /// /// Service collection to create hosted worker on. /// Client target host to connect to when starting the @@ -27,37 +27,59 @@ public static class TemporalHostingServiceCollectionExtensions /// Client namespace to connect to when starting the worker. /// /// Task queue for the worker. + /// + /// Build ID for the worker. Set to non-null to opt in to versioning. If versioning is + /// wanted, this must be set here and not later via configure options. This is because the + /// combination of task queue and build ID make up the unique identifier for a worker in the + /// service collection. + /// /// Options builder to configure the service. public static ITemporalWorkerServiceOptionsBuilder AddHostedTemporalWorker( this IServiceCollection services, string clientTargetHost, string clientNamespace, - string taskQueue) => - services.AddHostedTemporalWorker(taskQueue).ConfigureOptions(options => + string taskQueue, + string? buildId = null) => + services.AddHostedTemporalWorker(taskQueue, buildId).ConfigureOptions(options => options.ClientOptions = new(clientTargetHost) { Namespace = clientNamespace }); /// /// Add a hosted Temporal worker service as a that expects /// an injected (or the returned builder /// can have client options populated). Use - /// to - /// not expect an injected instance and instead connect to a client on worker start. The + /// + /// to not expect an injected instance and instead connect to a client on worker start. The /// worker service will be registered as a singleton. The result is an options builder that /// can be used to configure the service. /// /// Service collection to create hosted worker on. /// Task queue for the worker. + /// + /// Build ID for the worker. Set to non-null to opt in to versioning. If versioning is + /// wanted, this must be set here and not later via configure options. This is because the + /// combination of task queue and build ID make up the unique identifier for a worker in the + /// service collection. + /// /// Options builder to configure the service. public static ITemporalWorkerServiceOptionsBuilder AddHostedTemporalWorker( - this IServiceCollection services, string taskQueue) + this IServiceCollection services, string taskQueue, string? buildId = null) { // We have to use AddSingleton instead of AddHostedService because the latter does // not allow us to register multiple of the same type, see - // https://github.com/dotnet/runtime/issues/38751 + // https://github.com/dotnet/runtime/issues/38751. services.AddSingleton(provider => - ActivatorUtilities.CreateInstance(provider, taskQueue)); - return new TemporalWorkerServiceOptionsBuilder(taskQueue, services).ConfigureOptions( - options => options.TaskQueue = taskQueue); + ActivatorUtilities.CreateInstance( + provider, (TaskQueue: taskQueue, BuildId: buildId))); + return new TemporalWorkerServiceOptionsBuilder(taskQueue, buildId, services).ConfigureOptions( + options => + { + options.TaskQueue = taskQueue; + options.BuildId = buildId; + options.UseWorkerVersioning = buildId != null; + }, + // Disallow duplicate options registrations because that means multiple worker + // services with the same task queue + build ID were added. + disallowDuplicates: true); } /// diff --git a/src/Temporalio.Extensions.Hosting/TemporalWorkerService.cs b/src/Temporalio.Extensions.Hosting/TemporalWorkerService.cs index 82720398..d9534e81 100644 --- a/src/Temporalio.Extensions.Hosting/TemporalWorkerService.cs +++ b/src/Temporalio.Extensions.Hosting/TemporalWorkerService.cs @@ -68,11 +68,11 @@ public TemporalWorkerService( /// /// Initializes a new instance of the class using - /// options and possibly an existing client. This constructor is mostly used by DI - /// containers. The task queue is used as the name on the options monitor to lookup the - /// options for the worker service. + /// options and possibly an existing client. This constructor was used by DI + /// containers and is now DEPRECATED. /// - /// Task queue which is the options name. + /// Task queue which is included in the options name. + /// Build ID which is included in the options name. /// Used to lookup the options to build the worker with. /// /// Existing client to use if the options don't specify @@ -80,14 +80,63 @@ public TemporalWorkerService( /// Logger factory to use if not already on the worker options. /// The worker options logger factory or this one will be also be used for the client if an /// existing client does not exist (regardless of client options' logger factory). - [ActivatorUtilitiesConstructor] + [Obsolete("Deprecated older form of DI constructor, task queue + build ID tuple one is used instead.")] public TemporalWorkerService( string taskQueue, + string? buildId, IOptionsMonitor optionsMonitor, ITemporalClient? existingClient = null, ILoggerFactory? loggerFactory = null) + : this((taskQueue, buildId), optionsMonitor, existingClient, loggerFactory) { - var options = (TemporalWorkerServiceOptions)optionsMonitor.Get(taskQueue).Clone(); + } + + /// + /// Initializes a new instance of the class using + /// options and possibly an existing client. This constructor is only for use by DI + /// containers. The task queue and build ID are used as the name for the options monitor to + /// lookup the options for the worker service. + /// + /// Task queue and build ID for the options name. + /// Used to lookup the options to build the worker with. + /// + /// Existing client to use if the options don't specify + /// client connection options (connected when run if lazy and not connected). + /// Logger factory to use if not already on the worker options. + /// The worker options logger factory or this one will be also be used for the client if an + /// existing client does not exist (regardless of client options' logger factory). + /// + /// WARNING: Do not rely on the signature of this constructor, it is for DI container use + /// only and may change in incompatible ways. + /// + [ActivatorUtilitiesConstructor] + public TemporalWorkerService( + (string TaskQueue, string? BuildId) taskQueueAndBuildId, + IOptionsMonitor optionsMonitor, + ITemporalClient? existingClient = null, + ILoggerFactory? loggerFactory = null) + { + var options = (TemporalWorkerServiceOptions)optionsMonitor.Get( + TemporalWorkerServiceOptions.GetUniqueOptionsName( + taskQueueAndBuildId.TaskQueue, taskQueueAndBuildId.BuildId)).Clone(); + + // Make sure options values match the ones given in constructor + if (options.TaskQueue != taskQueueAndBuildId.TaskQueue) + { + throw new InvalidOperationException( + $"Task queue '{taskQueueAndBuildId.TaskQueue}' on constructor different than '{options.TaskQueue}' on options"); + } + if (options.BuildId != taskQueueAndBuildId.BuildId) + { + throw new InvalidOperationException( + $"Build ID '{taskQueueAndBuildId.BuildId ?? ""}' on constructor different than '{options.BuildId ?? ""}' on options"); + } + if (options.UseWorkerVersioning != (taskQueueAndBuildId.BuildId != null)) + { + throw new InvalidOperationException( + $"Use versioning option is {options.UseWorkerVersioning}, but constructor expects different"); + } + newClientOptions = options.ClientOptions; if (newClientOptions == null) { diff --git a/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptions.cs b/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptions.cs index 1e95e8f8..21c0466f 100644 --- a/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptions.cs +++ b/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptions.cs @@ -41,5 +41,20 @@ public override object Clone() } return options; } + + /// + /// Get an options name for the given task queue and build ID. + /// + /// Task queue. + /// Build ID. + /// Unique string name for the options. + internal static string GetUniqueOptionsName(string taskQueue, string? buildId) + { + if (buildId == null) + { + return taskQueue; + } + return taskQueue + "!!__temporal__!!" + buildId; + } } } \ No newline at end of file diff --git a/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilder.cs b/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilder.cs index c125f51e..b3f3d5ea 100644 --- a/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilder.cs +++ b/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilder.cs @@ -14,14 +14,30 @@ public class TemporalWorkerServiceOptionsBuilder : ITemporalWorkerServiceOptions /// Task queue for the worker. /// Service collection being configured. public TemporalWorkerServiceOptionsBuilder(string taskQueue, IServiceCollection services) + : this(taskQueue, null, services) + { + } + + /// + /// Initializes a new instance of the + /// class. + /// + /// Task queue for the worker. + /// Build ID for the worker. + /// Service collection being configured. + public TemporalWorkerServiceOptionsBuilder(string taskQueue, string? buildId, IServiceCollection services) { TaskQueue = taskQueue; + BuildId = buildId; Services = services; } /// public string TaskQueue { get; private init; } + /// + public string? BuildId { get; private init; } + /// public IServiceCollection Services { get; private init; } } diff --git a/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilderExtensions.cs b/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilderExtensions.cs index de6d8aef..a29fca86 100644 --- a/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilderExtensions.cs +++ b/src/Temporalio.Extensions.Hosting/TemporalWorkerServiceOptionsBuilderExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -167,22 +168,46 @@ public static ITemporalWorkerServiceOptionsBuilder AddWorkflow( /// Get an options builder to configure worker service options. /// /// Builder to use. + /// If true, will fail if options already registered for + /// this builder's task queue and build ID. /// Options builder. public static OptionsBuilder ConfigureOptions( - this ITemporalWorkerServiceOptionsBuilder builder) => - builder.Services.AddOptions(builder.TaskQueue); + this ITemporalWorkerServiceOptionsBuilder builder, + bool disallowDuplicates = false) + { + // To ensure the user isn't accidentally registering a duplicate task queue + build ID + // worker, we check here that there aren't duplicate options + var optionsName = TemporalWorkerServiceOptions.GetUniqueOptionsName( + builder.TaskQueue, builder.BuildId); + if (disallowDuplicates) + { + var any = builder.Services.Any(s => + s.ImplementationInstance is ConfigureNamedOptions instance && + instance.Name == optionsName); + if (any) + { + throw new InvalidOperationException( + $"Worker service for task queue '{builder.TaskQueue}' and build ID '{builder.BuildId ?? ""}' already on collection"); + } + } + + return builder.Services.AddOptions(optionsName); + } /// /// Configure worker service options using an action. /// /// Builder to use. /// Configuration action. + /// If true, will fail if options already registered for + /// this builder's task queue and build ID. /// Same builder instance. public static ITemporalWorkerServiceOptionsBuilder ConfigureOptions( this ITemporalWorkerServiceOptionsBuilder builder, - Action configure) + Action configure, + bool disallowDuplicates = false) { - builder.ConfigureOptions().Configure(configure); + builder.ConfigureOptions(disallowDuplicates).Configure(configure); return builder; } diff --git a/tests/Temporalio.Tests/Extensions/Hosting/TemporalWorkerServiceTests.cs b/tests/Temporalio.Tests/Extensions/Hosting/TemporalWorkerServiceTests.cs index fb82db6b..ae3494d3 100644 --- a/tests/Temporalio.Tests/Extensions/Hosting/TemporalWorkerServiceTests.cs +++ b/tests/Temporalio.Tests/Extensions/Hosting/TemporalWorkerServiceTests.cs @@ -202,4 +202,72 @@ public async Task TemporalWorkerService_ExecuteAsync_MultipleWorkers() }, result); } + + [Workflow("Workflow")] + public class WorkflowV1 + { + [WorkflowRun] + public async Task RunAsync() => "done-v1"; + } + + [Workflow("Workflow")] + public class WorkflowV2 + { + [WorkflowRun] + public async Task RunAsync() => "done-v2"; + } + + [Fact] + public async Task TemporalWorkerService_ExecuteAsync_MultipleVersionsSameQueue() + { + var taskQueue = $"tq-{Guid.NewGuid()}"; + // Build with two workers on same queue but different versions + var bld = Host.CreateApplicationBuilder(); + bld.Services.AddSingleton(Client); + bld.Services. + AddHostedTemporalWorker(taskQueue, "1.0"). + AddWorkflow(); + bld.Services. + AddHostedTemporalWorker(taskQueue, "2.0"). + AddWorkflow(); + + // Start the host + using var tokenSource = new CancellationTokenSource(); + using var host = bld.Build(); + var hostTask = Task.Run(() => host.RunAsync(tokenSource.Token)); + + // Set 1.0 as default and run + await Env.Client.UpdateWorkerBuildIdCompatibilityAsync( + taskQueue, new BuildIdOp.AddNewDefault("1.0")); + var res = await Client.ExecuteWorkflowAsync( + (WorkflowV1 wf) => wf.RunAsync(), + new($"wf-{Guid.NewGuid()}", taskQueue)); + Assert.Equal("done-v1", res); + + // Update default and run again + await Env.Client.UpdateWorkerBuildIdCompatibilityAsync( + taskQueue, new BuildIdOp.AddNewDefault("2.0")); + res = await Client.ExecuteWorkflowAsync( + (WorkflowV1 wf) => wf.RunAsync(), + new($"wf-{Guid.NewGuid()}", taskQueue)); + Assert.Equal("done-v2", res); + } + + [Fact] + public async Task TemporalWorkerService_ExecuteAsync_DuplicateQueue() + { + var taskQueue = $"tq-{Guid.NewGuid()}"; + // Build with two workers on same queue but different versions + var bld = Host.CreateApplicationBuilder(); + bld.Services.AddSingleton(Client); + bld.Services. + AddHostedTemporalWorker(taskQueue). + AddWorkflow(); + var exc = Assert.Throws(() => + bld.Services. + AddHostedTemporalWorker(taskQueue). + AddWorkflow()); + Assert.StartsWith("Worker service", exc.Message); + Assert.EndsWith("already on collection", exc.Message); + } } \ No newline at end of file