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