From 258277a1e016235e48f1ea6b651e331f7dd81707 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 18 Nov 2025 09:10:21 -0800 Subject: [PATCH 01/19] Scaffold playground project. --- Aspire.slnx | 4 ++ Directory.Packages.props | 3 ++ .../AzureFunctionsWithDts.AppHost.csproj | 22 ++++++++++ .../AzureFunctionsWithDts.AppHost/Program.cs | 19 ++++++++ .../Properties/launchSettings.json | 41 ++++++++++++++++++ .../AzureFunctionsWithDts.Functions.csproj | 43 +++++++++++++++++++ .../MyOrchestrationTrigger.cs | 29 +++++++++++++ .../Program.cs | 12 ++++++ .../Properties/launchSettings.json | 9 ++++ .../AzureFunctionsWithDts.Functions/host.json | 22 ++++++++++ 10 files changed, 204 insertions(+) create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/AzureFunctionsWithDts.AppHost.csproj create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Properties/launchSettings.json create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/AzureFunctionsWithDts.Functions.csproj create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/MyOrchestrationTrigger.cs create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Program.cs create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json create mode 100644 playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/host.json diff --git a/Aspire.slnx b/Aspire.slnx index 028a63070c9..5ea67fef241 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -133,6 +133,10 @@ + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 48bf97791f5..bd31757f015 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -158,6 +158,9 @@ + + + diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/AzureFunctionsWithDts.AppHost.csproj b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/AzureFunctionsWithDts.AppHost.csproj new file mode 100644 index 00000000000..141e339b6f1 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/AzureFunctionsWithDts.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + $(DefaultTargetFramework) + enable + enable + true + DC3A64A6-3991-41E2-956F-BFACC8091EC1 + + + + + + + + + + + + + diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs new file mode 100644 index 00000000000..b78c76935e0 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs @@ -0,0 +1,19 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + +var dts = builder.AddContainer("dts", "mcr.microsoft.com/dts/dts-emulator", "latest") + .WithEndpoint(name: "grpc", targetPort: 8080) + .WithEndpoint(name: "http", targetPort: 8082); + +var grpcEndpoint = dts.GetEndpoint("grpc"); + +ReferenceExpression dtsConnectionString = ReferenceExpression.Create($"Endpoint=http://{grpcEndpoint.Property(EndpointProperty.Host)}:{grpcEndpoint.Property(EndpointProperty.Port)};Authentication=None"); + +builder.AddAzureFunctionsProject("funcapp") + .WithHostStorage(storage) + .WaitFor(dts) + .WithEnvironment("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", dtsConnectionString) + .WithEnvironment("TASKHUB_NAME", "default"); + +builder.Build().Run(); diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Properties/launchSettings.json b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..f5f441697c0 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17244;http://localhost:15054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21003", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22110" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19010", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20125" + } + }, + "generate-manifest": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json", + "applicationUrl": "http://localhost:15888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:16175" + } + } + } +} diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/AzureFunctionsWithDts.Functions.csproj b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/AzureFunctionsWithDts.Functions.csproj new file mode 100644 index 00000000000..ca8d05550d1 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/AzureFunctionsWithDts.Functions.csproj @@ -0,0 +1,43 @@ + + + $(DefaultTargetFramework) + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + + + + + + + + + + + + + diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/MyOrchestrationTrigger.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/MyOrchestrationTrigger.cs new file mode 100644 index 00000000000..b0c9f43129d --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/MyOrchestrationTrigger.cs @@ -0,0 +1,29 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.Extensions.Logging; + +public class MyOrchestrationTrigger +{ + [Function("Chaining")] + public static async Task Run( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + ILogger logger = context.CreateReplaySafeLogger(nameof(MyOrchestrationTrigger)); + logger.LogInformation("Saying hello."); + var outputs = new List(); + + // Replace name and input with values relevant for your Durable Functions Activity + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Tokyo")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Seattle")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "London")); + + // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + return outputs; + } + + [Function(nameof(SayHello))] + public static string SayHello([ActivityTrigger] string name, FunctionContext executionContext) + { + return $"Hello {name}!"; + } +} \ No newline at end of file diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Program.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Program.cs new file mode 100644 index 00000000000..2ede5b1469f --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Program.cs @@ -0,0 +1,12 @@ +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.Hosting; + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.ConfigureFunctionsWebApplication(); + +var host = builder.Build(); + +host.Run(); diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json new file mode 100644 index 00000000000..5b99a0a49ae --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "AzureFunctionsEndToEnd_Functions": { + "commandName": "Project", + "commandLineArgs": "--port 7071", + "launchBrowser": false + } + } +} diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/host.json b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/host.json new file mode 100644 index 00000000000..455e0e8d677 --- /dev/null +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/host.json @@ -0,0 +1,22 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%", + "storageProvider": { + "type": "azureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + }, + "telemetryMode": "openTelemetry" +} \ No newline at end of file From dd4b06dd3d1557060b171180cbf53866f8f225b5 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 18 Nov 2025 10:42:19 -0800 Subject: [PATCH 02/19] Sketch DTS resources. --- .../AzureFunctionsWithDts.AppHost/Program.cs | 14 ++-- .../DurableTask/DurableTaskHubResource.cs | 21 ++++++ .../DurableTaskResourceExtensions.cs | 72 +++++++++++++++++++ ...TaskSchedulerEmulatorContainerImageTags.cs | 16 +++++ .../DurableTaskSchedulerEmulatorResource.cs | 21 ++++++ .../DurableTaskSchedulerResource.cs | 31 ++++++++ 6 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs create mode 100644 src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs create mode 100644 src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorContainerImageTags.cs create mode 100644 src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs create mode 100644 src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs index b78c76935e0..34efde4c56c 100644 --- a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs @@ -1,19 +1,17 @@ +using Azure.Hosting; + var builder = DistributedApplication.CreateBuilder(args); var storage = builder.AddAzureStorage("storage").RunAsEmulator(); -var dts = builder.AddContainer("dts", "mcr.microsoft.com/dts/dts-emulator", "latest") - .WithEndpoint(name: "grpc", targetPort: 8080) - .WithEndpoint(name: "http", targetPort: 8082); - -var grpcEndpoint = dts.GetEndpoint("grpc"); +var dts = builder.AddDurableTaskScheduler("dts").RunAsEmulator(); -ReferenceExpression dtsConnectionString = ReferenceExpression.Create($"Endpoint=http://{grpcEndpoint.Property(EndpointProperty.Host)}:{grpcEndpoint.Property(EndpointProperty.Port)};Authentication=None"); +var taskHub = dts.AddTaskHub("default"); builder.AddAzureFunctionsProject("funcapp") .WithHostStorage(storage) .WaitFor(dts) - .WithEnvironment("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", dtsConnectionString) - .WithEnvironment("TASKHUB_NAME", "default"); + .WithEnvironment("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", dts) + .WithEnvironment("TASKHUB_NAME", taskHub.Resource.Name); builder.Build().Run(); diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs new file mode 100644 index 00000000000..551f126f45e --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Represents a Durable Task hub resource. A Task Hub groups durable orchestrations and activities. +/// This resource extends the scheduler connection string with the TaskHub name so that clients can +/// connect to the correct hub. +/// +/// The logical name of the Task Hub (used as the TaskHub value). +/// The durable task scheduler resource whose connection string is the base for this hub. +public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler) : Resource(name), IResourceWithConnectionString +{ + /// + /// Gets the connection string expression composed of the scheduler connection string and the TaskHub name. + /// + public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{scheduler.ConnectionStringExpression};TaskHub={Name}"); +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs new file mode 100644 index 00000000000..1312f24c8f2 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.DurableTask; + +namespace Azure.Hosting; + +/// +/// Extension methods for adding and configuring Durable Task resources within a distributed application. +/// +public static class DurableTaskResourceExtensions +{ + /// + /// Adds a Durable Task scheduler resource to the distributed application. + /// + /// The distributed application builder. + /// The logical name of the scheduler resource. + /// An for the scheduler resource. + public static IResourceBuilder AddDurableTaskScheduler(this IDistributedApplicationBuilder builder, string name) + { + var scheduler = new DurableTaskSchedulerResource(name); + return builder.AddResource(scheduler); + } + + /// + /// Configures the Durable Task scheduler to run using the local emulator (only in non-publish modes). + /// + /// The resource builder for the scheduler. + /// The same instance for chaining. + public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + return builder; + } + + // Mark this resource as an emulator for consistent resource identification and tooling support + builder.WithAnnotation(new EmulatorResourceAnnotation()); + + builder.WithEndpoint(name: "grpc", targetPort: 8080) + .WithHttpEndpoint(name: "http", targetPort: 8081) + .WithHttpEndpoint(name: "dashboard", targetPort: 8082) + .WithAnnotation(new ContainerImageAnnotation + { + Registry = DurableTaskSchedulerEmulatorContainerImageTags.Registry, + Image = DurableTaskSchedulerEmulatorContainerImageTags.Image, + Tag = DurableTaskSchedulerEmulatorContainerImageTags.Tag + }); + + var emulatorResource = new DurableTaskSchedulerEmulatorResource(builder.Resource); + + builder.ApplicationBuilder.CreateResourceBuilder(emulatorResource); + + return builder; + } + + /// + /// Adds a Durable Task hub resource associated with the specified scheduler. + /// + /// The scheduler resource builder. + /// The logical name of the task hub resource. + /// An for the task hub resource. + public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name) + { + var hub = new DurableTaskHubResource(name, builder.Resource); + return builder.ApplicationBuilder.AddResource(hub); + } +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorContainerImageTags.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorContainerImageTags.cs new file mode 100644 index 00000000000..015f8bd2784 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorContainerImageTags.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Azure.DurableTask; + +internal static class DurableTaskSchedulerEmulatorContainerImageTags +{ + /// mcr.microsoft.com + public const string Registry = "mcr.microsoft.com"; + + /// dts/dts-emulator + public const string Image = "dts/dts-emulator"; + + /// latest + public const string Tag = "latest"; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs new file mode 100644 index 00000000000..da207a102d9 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerEmulatorResource.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Represents the containerized emulator resource for a . +/// This is used to host the Durable Task scheduler logic when running locally (e.g. with an Azure Functions emulator). +/// +/// The underlying durable task scheduler resource that provides naming and annotations. +/// +/// The emulator resource delegates its annotation collection to the underlying scheduler so that configuration +/// and metadata remain consistent across both representations. +/// +public sealed class DurableTaskSchedulerEmulatorResource(DurableTaskSchedulerResource scheduler) : ContainerResource(scheduler.Name) +{ + /// + public override ResourceAnnotationCollection Annotations => scheduler.Annotations; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs new file mode 100644 index 00000000000..f16e18b9f97 --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Represents a Durable Task scheduler resource used in Aspire hosting that provides endpoints +/// and a connection string for Durable Task orchestration scheduling. +/// +/// The unique resource name. +public sealed class DurableTaskSchedulerResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithConnectionString +{ + /// + /// Gets the expression that resolves to the connection string for the Durable Task scheduler. + /// + public ReferenceExpression ConnectionStringExpression => CreateConnectionString(); + + private ReferenceExpression CreateConnectionString() + { + if (this.IsContainer()) + { + var grpcEndpoint = new EndpointReference(this, "grpc"); + + return ReferenceExpression.Create($"Endpoint=http://{grpcEndpoint.Property(EndpointProperty.Host)}:{grpcEndpoint.Property(EndpointProperty.Port)};Authentication=None"); + } + + throw new NotImplementedException(); + } +} From 416a8595c725e5c21fa31a9d3b4fdd2815f230d9 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 18 Nov 2025 16:16:27 -0800 Subject: [PATCH 03/19] Expose URLs to dashboards. --- .../DurableTask/DurableTaskHubResource.cs | 10 ++++++-- .../DurableTaskResourceExtensions.cs | 24 ++++++++++++++++++- .../DurableTaskSchedulerResource.cs | 22 ++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs index 551f126f45e..4236de4f3f8 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs @@ -12,10 +12,16 @@ namespace Aspire.Hosting.Azure.DurableTask; /// /// The logical name of the Task Hub (used as the TaskHub value). /// The durable task scheduler resource whose connection string is the base for this hub. -public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler) : Resource(name), IResourceWithConnectionString +public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler) + : Resource(name), IResourceWithConnectionString, IResourceWithParent { /// /// Gets the connection string expression composed of the scheduler connection string and the TaskHub name. /// - public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{scheduler.ConnectionStringExpression};TaskHub={Name}"); + public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{Parent.ConnectionStringExpression};TaskHub={Name}"); + + /// + /// Gets the parent durable task scheduler resource that provides the base connection string. + /// + public DurableTaskSchedulerResource Parent => scheduler; } diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 1312f24c8f2..aae3144dc90 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -4,6 +4,7 @@ using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.DurableTask; +using Microsoft.Extensions.DependencyInjection; namespace Azure.Hosting; @@ -44,6 +45,7 @@ public static IResourceBuilder RunAsEmulator(this builder.WithEndpoint(name: "grpc", targetPort: 8080) .WithHttpEndpoint(name: "http", targetPort: 8081) .WithHttpEndpoint(name: "dashboard", targetPort: 8082) + .WithUrlForEndpoint("dashboard", c => c.DisplayText = "Scheduler Dashboard") .WithAnnotation(new ContainerImageAnnotation { Registry = DurableTaskSchedulerEmulatorContainerImageTags.Registry, @@ -67,6 +69,26 @@ public static IResourceBuilder RunAsEmulator(this public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name) { var hub = new DurableTaskHubResource(name, builder.Resource); - return builder.ApplicationBuilder.AddResource(hub); + + var hubBuilder = builder.ApplicationBuilder.AddResource(hub); + + if (builder.Resource.IsEmulator) + { + hubBuilder.OnResourceReady( + async (r, e, ct) => + { + var notifications = e.Services.GetRequiredService(); + + var url = await ReferenceExpression.Create($"{r.Parent.EmulatorDashboardEndpoint}/subscriptions/default/schedulers/default/taskhubs/{r.Name}").GetValueAsync(ct).ConfigureAwait(false); + + await notifications.PublishUpdateAsync(r, snapshot => snapshot with + { + State = KnownResourceStates.Running, + Urls = [new("dashboard", url ?? throw new InvalidOperationException("URL cannot be null"), false) { DisplayProperties = new() { DisplayName = "Task Hub Dashboard" } }] + }).ConfigureAwait(false); + }); + } + + return hubBuilder; } } diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs index f16e18b9f97..8a5bc5b5ca6 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs @@ -17,9 +17,17 @@ public sealed class DurableTaskSchedulerResource(string name) : Resource(name), /// public ReferenceExpression ConnectionStringExpression => CreateConnectionString(); + internal ReferenceExpression EmulatorDashboardEndpoint => CreateDashboardEndpoint(); + + /// + /// Gets a value indicating whether the Durable Task scheduler is running using the local + /// emulator (container) instead of a cloud-hosted service. + /// + public bool IsEmulator => this.IsContainer(); + private ReferenceExpression CreateConnectionString() { - if (this.IsContainer()) + if (IsEmulator) { var grpcEndpoint = new EndpointReference(this, "grpc"); @@ -28,4 +36,16 @@ private ReferenceExpression CreateConnectionString() throw new NotImplementedException(); } + + private ReferenceExpression CreateDashboardEndpoint() + { + if (IsEmulator) + { + var dashboardEndpoint = new EndpointReference(this, "dashboard"); + + return ReferenceExpression.Create($"http://{dashboardEndpoint.Property(EndpointProperty.Host)}:{dashboardEndpoint.Property(EndpointProperty.Port)}"); + } + + throw new NotImplementedException(); + } } From 02264cb6de97c518922f1ef6eb7b450c461883fd Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 18 Nov 2025 21:31:17 -0800 Subject: [PATCH 04/19] Add support for existing DTS instances. --- .../DurableTaskResourceExtensions.cs | 18 ++++++++++++++++++ ...eTaskSchedulerConnectionStringAnnotation.cs | 18 ++++++++++++++++++ .../DurableTaskSchedulerResource.cs | 9 +++++++++ 3 files changed, 45 insertions(+) create mode 100644 src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index aae3144dc90..42fcec01059 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -25,6 +25,24 @@ public static IResourceBuilder AddDurableTaskSched return builder.AddResource(scheduler); } + /// + /// Configures the Durable Task scheduler to use an existing scheduler instance referenced by the provided connection string. + /// No new scheduler resource is provisioned. + /// + /// The scheduler resource builder. + /// The connection string referencing the existing Durable Task scheduler instance. + /// The same instance for fluent chaining. + /// The existing resource annotation is only applied when the execution context is not in publish mode. + public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, string connectionString) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithAnnotation(new DurableTaskSchedulerConnectionStringAnnotation(connectionString)); + } + + return builder; + } + /// /// Configures the Durable Task scheduler to run using the local emulator (only in non-publish modes). /// diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs new file mode 100644 index 00000000000..4367303211b --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Annotation that supplies the connection string for an existing Durable Task scheduler resource. +/// +/// The connection string of the existing Durable Task scheduler. +public sealed class DurableTaskSchedulerConnectionStringAnnotation(object connectionString) : IResourceAnnotation +{ + /// + /// Gets the connection string of the existing Durable Task scheduler. + /// + public object ConnectionString { get; } = connectionString; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs index 8a5bc5b5ca6..6730747936c 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs @@ -34,6 +34,15 @@ private ReferenceExpression CreateConnectionString() return ReferenceExpression.Create($"Endpoint=http://{grpcEndpoint.Property(EndpointProperty.Host)}:{grpcEndpoint.Property(EndpointProperty.Port)};Authentication=None"); } + static ReferenceExpression CreateReferenceExpression(object? value) => value is IResourceBuilder parameterResource + ? ReferenceExpression.Create($"{parameterResource}") + : ReferenceExpression.Create($"{value?.ToString() ?? String.Empty}"); + + if (this.TryGetLastAnnotation(out var connectionStringAnnotation)) + { + return CreateReferenceExpression(connectionStringAnnotation.ConnectionString); + } + throw new NotImplementedException(); } From 7bdbafc69a7a3e3f08aeb3a83669a86f20e2b6b5 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 21 Nov 2025 16:29:33 -0800 Subject: [PATCH 05/19] Start writing tests. --- .../DurableTaskResourceExtensions.cs | 28 +++++- .../DurableTaskResourceExtensionsTests.cs | 85 +++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 42fcec01059..5b9aab0b7be 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -1,12 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting; using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Azure.DurableTask; using Microsoft.Extensions.DependencyInjection; -namespace Azure.Hosting; +namespace Aspire.Hosting; /// /// Extension methods for adding and configuring Durable Task resources within a distributed application. @@ -43,12 +42,31 @@ public static IResourceBuilder RunAsExisting(this return builder; } + /// + /// Configures the Durable Task scheduler to use an existing scheduler instance referenced by the provided connection string. + /// No new scheduler resource is provisioned. + /// + /// The scheduler resource builder. + /// The connection string parameter referencing the existing Durable Task scheduler instance. + /// The same instance for fluent chaining. + /// The existing resource annotation is only applied when the execution context is not in publish mode. + public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, IResourceBuilder connectionString) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) + { + builder.WithAnnotation(new DurableTaskSchedulerConnectionStringAnnotation(connectionString)); + } + + return builder; + } + /// /// Configures the Durable Task scheduler to run using the local emulator (only in non-publish modes). /// /// The resource builder for the scheduler. + /// Callback that exposes underlying container used for emulation to allow for customization. /// The same instance for chaining. - public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder) + public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) { ArgumentNullException.ThrowIfNull(builder); @@ -73,7 +91,9 @@ public static IResourceBuilder RunAsEmulator(this var emulatorResource = new DurableTaskSchedulerEmulatorResource(builder.Resource); - builder.ApplicationBuilder.CreateResourceBuilder(emulatorResource); + var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(emulatorResource); + + configureContainer?.Invoke(surrogateBuilder); return builder; } diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs new file mode 100644 index 00000000000..e3d50050a44 --- /dev/null +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.Azure.Tests; + +public class DurableTaskResourceExtensionsTests +{ + [Fact] + public async Task AddDurableTaskScheduler_RunAsEmulator_ResolvedConnectionString() + { + string expectedConnectionString = "Endpoint=http://localhost:8080;Authentication=None"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsEmulator(e => + { + e.WithEndpoint("grpc", e => e.AllocatedEndpoint = new(e, "localhost", 8080)); + e.WithEndpoint("http", e => e.AllocatedEndpoint = new(e, "localhost", 8081)); + e.WithEndpoint("dashboard", e => e.AllocatedEndpoint = new(e, "localhost", 8082)); + }); + + var connectionString = await dts.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } + + [Fact] + public async Task AddDurableTaskScheduler_RunAsExisting_ResolvedConnectionString() + { + string expectedConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsExisting(expectedConnectionString); + + var connectionString = await dts.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } + + [Fact] + public async Task AddDurableTaskScheduler_RunAsExisting_ResolvedConnectionStringParameter() + { + string expectedConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var connectionStringParameter = builder.AddParameter("dts-connection-string", expectedConnectionString); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsExisting(connectionStringParameter); + + var connectionString = await dts.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } + + [Fact] + public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParameter() + { + string dtsConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + string expectedConnectionString = $"{dtsConnectionString};TaskHub=mytaskhub"; + + using var builder = TestDistributedApplicationBuilder.Create(); + + var connectionStringParameter = builder.AddParameter("dts-connection-string", expectedConnectionString); + + var dts = builder + .AddDurableTaskScheduler("dts") + .RunAsExisting(dtsConnectionString); + + var taskHub = dts.AddTaskHub("mytaskhub"); + + var connectionString = await taskHub.Resource.ConnectionStringExpression.GetValueAsync(default); + + Assert.Equal(expectedConnectionString, connectionString); + } +} From 59937aae93ccac53519777ef3fc2cd18d39dd87e Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Fri, 21 Nov 2025 16:44:53 -0800 Subject: [PATCH 06/19] Add more tests. --- .../DurableTask/DurableTaskHubResource.cs | 10 ++++++++-- .../DurableTask/DurableTaskResourceExtensions.cs | 6 +++--- .../DurableTaskResourceExtensionsTests.cs | 11 ++++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs index 4236de4f3f8..bc02c4e49be 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs @@ -12,16 +12,22 @@ namespace Aspire.Hosting.Azure.DurableTask; /// /// The logical name of the Task Hub (used as the TaskHub value). /// The durable task scheduler resource whose connection string is the base for this hub. -public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler) +/// The name of the Task Hub. If not provided, the logical name is used. +public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler, string? taskHubName = null) : Resource(name), IResourceWithConnectionString, IResourceWithParent { /// /// Gets the connection string expression composed of the scheduler connection string and the TaskHub name. /// - public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{Parent.ConnectionStringExpression};TaskHub={Name}"); + public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"{Parent.ConnectionStringExpression};TaskHub={TaskHubName}"); /// /// Gets the parent durable task scheduler resource that provides the base connection string. /// public DurableTaskSchedulerResource Parent => scheduler; + + /// + /// Gets the name of the Task Hub. If not provided, the logical name of this resource is returned. + /// + public string TaskHubName => taskHubName ?? Name; } diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 5b9aab0b7be..403cd62ea45 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -103,11 +103,11 @@ public static IResourceBuilder RunAsEmulator(this /// /// The scheduler resource builder. /// The logical name of the task hub resource. + /// The name of the Task Hub. If not provided, the logical name is used. /// An for the task hub resource. - public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name) + public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name, string? taskHubName = null) { - var hub = new DurableTaskHubResource(name, builder.Resource); - + var hub = new DurableTaskHubResource(name, builder.Resource, taskHubName); var hubBuilder = builder.ApplicationBuilder.AddResource(hub); if (builder.Resource.IsEmulator) diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs index e3d50050a44..1d135abe682 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -62,12 +62,13 @@ public async Task AddDurableTaskScheduler_RunAsExisting_ResolvedConnectionString Assert.Equal(expectedConnectionString, connectionString); } - [Fact] - public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParameter() + [Theory] + [InlineData(null, "mytaskhub")] + [InlineData("myrealtaskhub", "myrealtaskhub")] + public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParameter(string? taskHubName, string expectedTaskHubName) { string dtsConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; - string expectedConnectionString = $"{dtsConnectionString};TaskHub=mytaskhub"; - + string expectedConnectionString = $"{dtsConnectionString};TaskHub={expectedTaskHubName}"; using var builder = TestDistributedApplicationBuilder.Create(); var connectionStringParameter = builder.AddParameter("dts-connection-string", expectedConnectionString); @@ -76,7 +77,7 @@ public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParame .AddDurableTaskScheduler("dts") .RunAsExisting(dtsConnectionString); - var taskHub = dts.AddTaskHub("mytaskhub"); + var taskHub = dts.AddTaskHub("mytaskhub", taskHubName); var connectionString = await taskHub.Resource.ConnectionStringExpression.GetValueAsync(default); From e68430bbc62ce279493af7cf8b60e6aa9cee78ee Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 1 Dec 2025 15:44:29 -0800 Subject: [PATCH 07/19] Refactor how task hub names are defined. --- .../DurableTaskHubNameAnnotation.cs | 18 ++++++++++++ .../DurableTask/DurableTaskHubResource.cs | 20 +++++++++++-- .../DurableTaskResourceExtensions.cs | 29 ++++++++++++++++--- ...TaskSchedulerConnectionStringAnnotation.cs | 2 +- .../DurableTaskSchedulerResource.cs | 13 +++++---- 5 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubNameAnnotation.cs diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubNameAnnotation.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubNameAnnotation.cs new file mode 100644 index 00000000000..a5f760aacdd --- /dev/null +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubNameAnnotation.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.DurableTask; + +/// +/// Annotation that supplies the name for an existing Durable Task hub resource. +/// +/// The name of the existing Durable Task hub. +internal sealed class DurableTaskHubNameAnnotation(object hubName) : IResourceAnnotation +{ + /// + /// Gets the name of the existing Durable Task hub. + /// + public object HubName { get; } = hubName; +} diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs index bc02c4e49be..2e57c156407 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskHubResource.cs @@ -12,8 +12,7 @@ namespace Aspire.Hosting.Azure.DurableTask; /// /// The logical name of the Task Hub (used as the TaskHub value). /// The durable task scheduler resource whose connection string is the base for this hub. -/// The name of the Task Hub. If not provided, the logical name is used. -public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler, string? taskHubName = null) +public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerResource scheduler) : Resource(name), IResourceWithConnectionString, IResourceWithParent { /// @@ -29,5 +28,20 @@ public sealed class DurableTaskHubResource(string name, DurableTaskSchedulerReso /// /// Gets the name of the Task Hub. If not provided, the logical name of this resource is returned. /// - public string TaskHubName => taskHubName ?? Name; + public ReferenceExpression TaskHubName => GetTaskHubName(); + + private ReferenceExpression GetTaskHubName() + { + if (this.TryGetLastAnnotation(out var taskHubNameAnnotation)) + { + return taskHubNameAnnotation.HubName switch + { + ParameterResource parameter => ReferenceExpression.Create($"{parameter}"), + string hubName => ReferenceExpression.Create($"{hubName}"), + _ => throw new InvalidOperationException($"Unexpected Task Hub name type: {taskHubNameAnnotation.HubName.GetType().Name}") + }; + } + + return ReferenceExpression.Create($"{Name}"); + } } diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 403cd62ea45..93b8b4dce6b 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -54,7 +54,7 @@ public static IResourceBuilder RunAsExisting(this { if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) { - builder.WithAnnotation(new DurableTaskSchedulerConnectionStringAnnotation(connectionString)); + builder.WithAnnotation(new DurableTaskSchedulerConnectionStringAnnotation(connectionString.Resource)); } return builder; @@ -103,11 +103,10 @@ public static IResourceBuilder RunAsEmulator(this /// /// The scheduler resource builder. /// The logical name of the task hub resource. - /// The name of the Task Hub. If not provided, the logical name is used. /// An for the task hub resource. - public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name, string? taskHubName = null) + public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name) { - var hub = new DurableTaskHubResource(name, builder.Resource, taskHubName); + var hub = new DurableTaskHubResource(name, builder.Resource); var hubBuilder = builder.ApplicationBuilder.AddResource(hub); if (builder.Resource.IsEmulator) @@ -129,4 +128,26 @@ await notifications.PublishUpdateAsync(r, snapshot => snapshot with return hubBuilder; } + + /// + /// Sets the name of the Durable Task hub. + /// + /// The task hub resource builder. + /// The name of the Task Hub. + /// The same instance for fluent chaining. + public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, string taskHubName) + { + return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName)); + } + + /// + /// Sets the name of the Durable Task hub using a parameter resource. + /// + /// The task hub resource builder. + /// A parameter resource that resolves to the Task Hub name. + /// The same instance for fluent chaining. + public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, IResourceBuilder taskHubName) + { + return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName.Resource)); + } } diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs index 4367303211b..c14ac070c45 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerConnectionStringAnnotation.cs @@ -9,7 +9,7 @@ namespace Aspire.Hosting.Azure.DurableTask; /// Annotation that supplies the connection string for an existing Durable Task scheduler resource. /// /// The connection string of the existing Durable Task scheduler. -public sealed class DurableTaskSchedulerConnectionStringAnnotation(object connectionString) : IResourceAnnotation +internal sealed class DurableTaskSchedulerConnectionStringAnnotation(object connectionString) : IResourceAnnotation { /// /// Gets the connection string of the existing Durable Task scheduler. diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs index 6730747936c..24ae82e76bd 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs @@ -34,16 +34,17 @@ private ReferenceExpression CreateConnectionString() return ReferenceExpression.Create($"Endpoint=http://{grpcEndpoint.Property(EndpointProperty.Host)}:{grpcEndpoint.Property(EndpointProperty.Port)};Authentication=None"); } - static ReferenceExpression CreateReferenceExpression(object? value) => value is IResourceBuilder parameterResource - ? ReferenceExpression.Create($"{parameterResource}") - : ReferenceExpression.Create($"{value?.ToString() ?? String.Empty}"); - if (this.TryGetLastAnnotation(out var connectionStringAnnotation)) { - return CreateReferenceExpression(connectionStringAnnotation.ConnectionString); + return connectionStringAnnotation.ConnectionString switch + { + ParameterResource parameterResource => ReferenceExpression.Create($"{parameterResource}"), + string value => ReferenceExpression.Create($"{value}"), + _ => throw new InvalidOperationException($"Unexpected connection string type: {connectionStringAnnotation.ConnectionString.GetType().Name}"), + }; } - throw new NotImplementedException(); + throw new InvalidOperationException("Unable to create the Durable Task Scheduler connection string."); } private ReferenceExpression CreateDashboardEndpoint() From 1a25e3b593331e69f54630ec6c1bd2c0ac68ec8a Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 1 Dec 2025 16:06:21 -0800 Subject: [PATCH 08/19] Set task hub name variable appropriately. --- .../DurableTaskResourceExtensions.cs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 93b8b4dce6b..fae204c79d8 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -91,7 +91,35 @@ public static IResourceBuilder RunAsEmulator(this var emulatorResource = new DurableTaskSchedulerEmulatorResource(builder.Resource); - var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(emulatorResource); + var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(emulatorResource) + .WithEnvironment( + context => + { + ReferenceExpressionBuilder builder1 = new(); + + var durableTaskHubNames = builder.ApplicationBuilder + .Resources + .OfType() + .Where(th => th.Parent == builder.Resource) + .Select(th => th.TaskHubName) + .ToList(); + + for (int i = 0; i < durableTaskHubNames.Count; i++) + { + if (i == 0) + { + builder1.AppendFormatted(durableTaskHubNames[i]); + } + else + { + builder1.AppendFormatted($", {durableTaskHubNames[i]}"); + } + } + + ReferenceExpression referenceExpression = builder1.Build(); + + context.EnvironmentVariables["DTS_TASK_HUB_NAMES"] = referenceExpression; + }); configureContainer?.Invoke(surrogateBuilder); @@ -116,7 +144,7 @@ public static IResourceBuilder AddTaskHub(this IResource { var notifications = e.Services.GetRequiredService(); - var url = await ReferenceExpression.Create($"{r.Parent.EmulatorDashboardEndpoint}/subscriptions/default/schedulers/default/taskhubs/{r.Name}").GetValueAsync(ct).ConfigureAwait(false); + var url = await ReferenceExpression.Create($"{r.Parent.EmulatorDashboardEndpoint}/subscriptions/default/schedulers/default/taskhubs/{r.TaskHubName}").GetValueAsync(ct).ConfigureAwait(false); await notifications.PublishUpdateAsync(r, snapshot => snapshot with { From aca31636a1cba50d9c041c97b2ec772d4a220f15 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 1 Dec 2025 16:18:23 -0800 Subject: [PATCH 09/19] Update test. --- .../DurableTaskResourceExtensions.cs | 59 ++++++++++--------- .../DurableTaskResourceExtensionsTests.cs | 7 ++- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index fae204c79d8..8008ea1424f 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -91,35 +91,38 @@ public static IResourceBuilder RunAsEmulator(this var emulatorResource = new DurableTaskSchedulerEmulatorResource(builder.Resource); - var surrogateBuilder = builder.ApplicationBuilder.CreateResourceBuilder(emulatorResource) - .WithEnvironment( - context => - { - ReferenceExpressionBuilder builder1 = new(); - - var durableTaskHubNames = builder.ApplicationBuilder - .Resources - .OfType() - .Where(th => th.Parent == builder.Resource) - .Select(th => th.TaskHubName) - .ToList(); - - for (int i = 0; i < durableTaskHubNames.Count; i++) - { - if (i == 0) - { - builder1.AppendFormatted(durableTaskHubNames[i]); - } - else + var surrogateBuilder = + builder + .ApplicationBuilder + .CreateResourceBuilder(emulatorResource) + .WithEnvironment( + context => { - builder1.AppendFormatted($", {durableTaskHubNames[i]}"); - } - } - - ReferenceExpression referenceExpression = builder1.Build(); - - context.EnvironmentVariables["DTS_TASK_HUB_NAMES"] = referenceExpression; - }); + ReferenceExpressionBuilder namesBuilder = new(); + + var durableTaskHubNames = + builder + .ApplicationBuilder + .Resources + .OfType() + .Where(th => th.Parent == builder.Resource) + .Select(th => th.TaskHubName) + .ToList(); + + for (int i = 0; i < durableTaskHubNames.Count; i++) + { + if (i == 0) + { + namesBuilder.AppendFormatted(durableTaskHubNames[i]); + } + else + { + namesBuilder.AppendFormatted($", {durableTaskHubNames[i]}"); + } + } + + context.EnvironmentVariables["DTS_TASK_HUB_NAMES"] = namesBuilder.Build(); + }); configureContainer?.Invoke(surrogateBuilder); diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs index 1d135abe682..57a33c29bf4 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -77,7 +77,12 @@ public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParame .AddDurableTaskScheduler("dts") .RunAsExisting(dtsConnectionString); - var taskHub = dts.AddTaskHub("mytaskhub", taskHubName); + var taskHub = dts.AddTaskHub("mytaskhub"); + + if (taskHubName is not null) + { + taskHub = taskHub.WithTaskHubName(taskHubName); + } var connectionString = await taskHub.Resource.ConnectionStringExpression.GetValueAsync(default); From 28cf4647fabb47a7abf54a3ed0d8e689e5d8d8ad Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 1 Dec 2025 16:35:37 -0800 Subject: [PATCH 10/19] For now, exclude DTS resources from manifest. --- .../DurableTask/DurableTaskResourceExtensions.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 8008ea1424f..ca61b02701a 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -21,6 +21,9 @@ public static class DurableTaskResourceExtensions public static IResourceBuilder AddDurableTaskScheduler(this IDistributedApplicationBuilder builder, string name) { var scheduler = new DurableTaskSchedulerResource(name); + + scheduler.Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); + return builder.AddResource(scheduler); } @@ -138,6 +141,9 @@ public static IResourceBuilder RunAsEmulator(this public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name) { var hub = new DurableTaskHubResource(name, builder.Resource); + + hub.Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore); + var hubBuilder = builder.ApplicationBuilder.AddResource(hub); if (builder.Resource.IsEmulator) From 52f74bb4f1e8d9fb875088395a1b493060c9a125 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 30 Dec 2025 10:19:16 -0800 Subject: [PATCH 11/19] Add more unit tests. --- .../DurableTaskResourceExtensions.cs | 10 +- .../DurableTaskResourceExtensionsTests.cs | 167 ++++++++++++++++++ 2 files changed, 171 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index ca61b02701a..327e728ec35 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -114,14 +114,12 @@ public static IResourceBuilder RunAsEmulator(this for (int i = 0; i < durableTaskHubNames.Count; i++) { - if (i == 0) + if (i > 0) { - namesBuilder.AppendFormatted(durableTaskHubNames[i]); - } - else - { - namesBuilder.AppendFormatted($", {durableTaskHubNames[i]}"); + namesBuilder.AppendLiteral(", "); } + + namesBuilder.AppendFormatted(durableTaskHubNames[i]); } context.EnvironmentVariables["DTS_TASK_HUB_NAMES"] = namesBuilder.Build(); diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs index 57a33c29bf4..2a8973d452f 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests.Utils; using Aspire.Hosting.Utils; namespace Aspire.Hosting.Azure.Tests; @@ -88,4 +90,169 @@ public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParame Assert.Equal(expectedConnectionString, connectionString); } + + [Fact] + public void AddDurableTaskScheduler_IsExcludedFromPublishingManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts"); + + Assert.True(dts.Resource.TryGetAnnotationsOfType(out var manifestAnnotations)); + var annotation = Assert.Single(manifestAnnotations); + Assert.Equal(ManifestPublishingCallbackAnnotation.Ignore, annotation); + } + + [Fact] + public void AddDurableTaskHub_IsExcludedFromPublishingManifest() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts").RunAsExisting("Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"); + var taskHub = dts.AddTaskHub("hub"); + + Assert.True(taskHub.Resource.TryGetAnnotationsOfType(out var manifestAnnotations)); + var annotation = Assert.Single(manifestAnnotations); + Assert.Equal(ManifestPublishingCallbackAnnotation.Ignore, annotation); + } + + [Fact] + public void RunAsExisting_InPublishMode_DoesNotApplyConnectionStringAnnotation() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsExisting("Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"); + + Assert.False(dts.ApplicationBuilder.ExecutionContext.IsRunMode); + Assert.True(dts.ApplicationBuilder.ExecutionContext.IsPublishMode); + + var ex = Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); + Assert.Contains("Unable to create the Durable Task Scheduler connection string", ex.Message); + } + + [Fact] + public void RunAsEmulator_InPublishMode_IsNoOp() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsEmulator(); + + Assert.False(dts.Resource.IsEmulator); + Assert.DoesNotContain(dts.Resource.Annotations, a => a is EmulatorResourceAnnotation); + + Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); + } + + [Fact] + public void RunAsEmulator_AddsEmulatorAnnotationContainerImageAndEndpoints() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsEmulator(); + + Assert.True(dts.Resource.IsEmulator); + + var emulatorAnnotation = dts.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(emulatorAnnotation); + + var containerImageAnnotation = dts.Resource.Annotations.OfType().SingleOrDefault(); + Assert.NotNull(containerImageAnnotation); + Assert.Equal("mcr.microsoft.com", containerImageAnnotation.Registry); + Assert.Equal("dts/dts-emulator", containerImageAnnotation.Image); + Assert.Equal("latest", containerImageAnnotation.Tag); + + var endpointAnnotations = dts.Resource.Annotations.OfType().ToList(); + + var grpc = endpointAnnotations.SingleOrDefault(e => e.Name == "grpc"); + Assert.NotNull(grpc); + Assert.Equal(8080, grpc.TargetPort); + + var http = endpointAnnotations.SingleOrDefault(e => e.Name == "http"); + Assert.NotNull(http); + Assert.Equal(8081, http.TargetPort); + Assert.Equal("http", http.UriScheme); + + var dashboard = endpointAnnotations.SingleOrDefault(e => e.Name == "dashboard"); + Assert.NotNull(dashboard); + Assert.Equal(8082, dashboard.TargetPort); + Assert.Equal("http", dashboard.UriScheme); + } + + [Fact] + public async Task RunAsEmulator_SetsSingleDtsTaskHubNamesEnvironmentVariable() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts").RunAsEmulator(); + + _ = dts.AddTaskHub("hub1").WithTaskHubName("realhub1"); + + var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("realhub1", env["DTS_TASK_HUB_NAMES"]); + } + + [Fact] + public async Task RunAsEmulator_SetsMultipleDtsTaskHubNamesEnvironmentVariable() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts").RunAsEmulator(); + + _ = dts.AddTaskHub("hub1"); + _ = dts.AddTaskHub("hub2").WithTaskHubName("realhub2"); + + var env = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("hub1, realhub2", env["DTS_TASK_HUB_NAMES"]); + } + + [Fact] + public async Task RunAsEmulator_DtsTaskHubNamesOnlyIncludesHubsForSameScheduler() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts1 = builder.AddDurableTaskScheduler("dts1").RunAsEmulator(); + var dts2 = builder.AddDurableTaskScheduler("dts2").RunAsEmulator(); + + _ = dts1.AddTaskHub("hub1"); + _ = dts2.AddTaskHub("hub2"); + + var env1 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts1.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + var env2 = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dts2.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance); + + Assert.Equal("hub1", env1["DTS_TASK_HUB_NAMES"]); + Assert.Equal("hub2", env2["DTS_TASK_HUB_NAMES"]); + } + + [Fact] + public async Task WithTaskHubName_Parameter_ResolvedConnectionString() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + const string dtsConnectionString = "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"; + var hubNameParameter = builder.AddParameter("hub-name", "parameterHub"); + + var dts = builder.AddDurableTaskScheduler("dts") + .RunAsExisting(dtsConnectionString); + + var hub = dts.AddTaskHub("ignored").WithTaskHubName(hubNameParameter); + + var connectionString = await hub.Resource.ConnectionStringExpression.GetValueAsync(default); + Assert.Equal($"{dtsConnectionString};TaskHub=parameterHub", connectionString); + } + + [Fact] + public void DurableTaskSchedulerResource_WithoutEmulatorOrExistingConnectionString_Throws() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var dts = builder.AddDurableTaskScheduler("dts"); + + var ex = Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); + Assert.Contains("Unable to create the Durable Task Scheduler connection string", ex.Message); + } } From 032f93020a726ecc73e226cdc65654e1b6dd54e5 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 30 Dec 2025 10:24:03 -0800 Subject: [PATCH 12/19] Update sample host. --- .../AzureFunctionsWithDts.AppHost/Program.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs index 34efde4c56c..44e7d42bc99 100644 --- a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.AppHost/Program.cs @@ -1,17 +1,14 @@ -using Azure.Hosting; - var builder = DistributedApplication.CreateBuilder(args); var storage = builder.AddAzureStorage("storage").RunAsEmulator(); -var dts = builder.AddDurableTaskScheduler("dts").RunAsEmulator(); +var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); -var taskHub = dts.AddTaskHub("default"); +var taskHub = scheduler.AddTaskHub("taskhub"); builder.AddAzureFunctionsProject("funcapp") .WithHostStorage(storage) - .WaitFor(dts) - .WithEnvironment("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", dts) - .WithEnvironment("TASKHUB_NAME", taskHub.Resource.Name); + .WithEnvironment("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", scheduler) + .WithEnvironment("TASKHUB_NAME", taskHub.Resource.TaskHubName); builder.Build().Run(); From 214f022d04e39b1c170338501444d4b3b219f4de Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 30 Dec 2025 10:32:33 -0800 Subject: [PATCH 13/19] Update README. --- src/Aspire.Hosting.Azure.Functions/README.md | 54 ++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/Aspire.Hosting.Azure.Functions/README.md b/src/Aspire.Hosting.Azure.Functions/README.md index 376ff172b7c..deb3d9e59a7 100644 --- a/src/Aspire.Hosting.Azure.Functions/README.md +++ b/src/Aspire.Hosting.Azure.Functions/README.md @@ -47,6 +47,60 @@ var app = builder.Build(); app.Run(); ``` +## Durable Task Scheduler (Durable Functions) + +The Azure Functions hosting library also provides resource APIs for using the Durable Task Scheduler (DTS) with Durable Functions. + +In the _AppHost.cs_ file of `AppHost`, add a Scheduler resource, create one or more Task Hubs, and pass the connection string and hub name to your Functions project: + +```csharp +using Aspire.Hosting; +using Aspire.Hosting.Azure; +using Aspire.Hosting.Azure.Functions; + +var builder = DistributedApplication.CreateBuilder(args); + +var storage = builder.AddAzureStorage("storage").RunAsEmulator(); + +var scheduler = builder.AddDurableTaskScheduler("scheduler") + .RunAsEmulator(); + +var taskHub = scheduler.AddTaskHub("taskhub"); + +builder.AddAzureFunctionsProject("funcapp") + .WithHostStorage(storage) + .WithEnvironment("DURABLE_TASK_SCHEDULER_CONNECTION_STRING", scheduler) + .WithEnvironment("TASKHUB_NAME", taskHub.Resource.TaskHubName); + +builder.Build().Run(); +``` + +### Use the DTS emulator + +`RunAsEmulator()` starts a local container running the Durable Task Scheduler emulator. + +When a Scheduler runs as an emulator, Aspire automatically provides: + +* A "Scheduler Dashboard" URL for the scheduler resource. +* A "Task Hub Dashboard" URL for each Task Hub resource. +* A `DTS_TASK_HUB_NAMES` environment variable on the emulator container listing the Task Hub names associated with that scheduler. + +### Use an existing Scheduler + +If you already have a Scheduler instance, configure the resource using its connection string: + +```csharp +var schedulerConnectionString = builder.AddParameter( + "dts-connection-string", + "Endpoint=https://existing-scheduler.durabletask.io;Authentication=DefaultAzure"); + +var scheduler = builder.AddDurableTaskScheduler("scheduler") + .RunAsExisting(schedulerConnectionString); + +var taskHubName = builder.AddParameter("taskhub-name", "mytaskhub"); +var taskHub = scheduler.AddTaskHub("taskhub").WithTaskHubName(taskHubName); +``` + ## Feedback & contributing https://github.com/dotnet/aspire From d1ed665bde7859d4cfe618979e45341c86003263 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Tue, 6 Jan 2026 13:26:02 -0800 Subject: [PATCH 14/19] Add examples to XML docs. --- .../DurableTaskResourceExtensions.cs | 83 ++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 327e728ec35..ca9834c1dd4 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -18,6 +18,15 @@ public static class DurableTaskResourceExtensions /// The distributed application builder. /// The logical name of the scheduler resource. /// An for the scheduler resource. + /// + /// + /// Add a Durable Task scheduler resource: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler"); + /// + /// + /// public static IResourceBuilder AddDurableTaskScheduler(this IDistributedApplicationBuilder builder, string name) { var scheduler = new DurableTaskSchedulerResource(name); @@ -34,7 +43,19 @@ public static IResourceBuilder AddDurableTaskSched /// The scheduler resource builder. /// The connection string referencing the existing Durable Task scheduler instance. /// The same instance for fluent chaining. - /// The existing resource annotation is only applied when the execution context is not in publish mode. + /// + /// + /// The existing resource annotation is only applied when the execution context is not in publish mode. + /// + /// + /// Use an existing scheduler instead of provisioning a new one: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsExisting("Endpoint=https://example;...;"); + /// + /// + /// public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, string connectionString) { if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) @@ -52,7 +73,21 @@ public static IResourceBuilder RunAsExisting(this /// The scheduler resource builder. /// The connection string parameter referencing the existing Durable Task scheduler instance. /// The same instance for fluent chaining. - /// The existing resource annotation is only applied when the execution context is not in publish mode. + /// + /// + /// The existing resource annotation is only applied when the execution context is not in publish mode. + /// + /// + /// Use an existing scheduler where the connection string is supplied via a parameter: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var schedulerConnectionString = builder.AddParameter("schedulerConnectionString"); + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsExisting(schedulerConnectionString); + /// + /// + /// public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, IResourceBuilder connectionString) { if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) @@ -69,6 +104,16 @@ public static IResourceBuilder RunAsExisting(this /// The resource builder for the scheduler. /// Callback that exposes underlying container used for emulation to allow for customization. /// The same instance for chaining. + /// + /// + /// Run the scheduler locally using the emulator: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler") + /// .RunAsEmulator(); + /// + /// + /// public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) { ArgumentNullException.ThrowIfNull(builder); @@ -136,6 +181,18 @@ public static IResourceBuilder RunAsEmulator(this /// The scheduler resource builder. /// The logical name of the task hub resource. /// An for the task hub resource. + /// + /// + /// Add a task hub under a scheduler: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); + /// + /// var hub = scheduler.AddTaskHub("hub") + /// .WithTaskHubName("MyTaskHub"); + /// + /// + /// public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name) { var hub = new DurableTaskHubResource(name, builder.Resource); @@ -170,6 +227,16 @@ await notifications.PublishUpdateAsync(r, snapshot => snapshot with /// The task hub resource builder. /// The name of the Task Hub. /// The same instance for fluent chaining. + /// + /// + /// Set the task hub name: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); + /// var hub = scheduler.AddTaskHub("hub").WithTaskHubName("MyTaskHub"); + /// + /// + /// public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, string taskHubName) { return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName)); @@ -181,6 +248,18 @@ public static IResourceBuilder WithTaskHubName(this IRes /// The task hub resource builder. /// A parameter resource that resolves to the Task Hub name. /// The same instance for fluent chaining. + /// + /// + /// Set the task hub name from a parameter: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// var taskHubName = builder.AddParameter("taskHubName"); + /// + /// var scheduler = builder.AddDurableTaskScheduler("scheduler").RunAsEmulator(); + /// var hub = scheduler.AddTaskHub("hub").WithTaskHubName(taskHubName); + /// + /// + /// public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, IResourceBuilder taskHubName) { return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName.Resource)); From b8f3a1bfb4f4ae372103ef22f640a03bc631fbb6 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Wed, 7 Jan 2026 09:44:58 -0800 Subject: [PATCH 15/19] Fixups per PR feedback. --- .../Properties/launchSettings.json | 2 +- .../DurableTask/DurableTaskResourceExtensions.cs | 4 +++- .../DurableTask/DurableTaskSchedulerResource.cs | 2 +- src/Aspire.Hosting.Azure.Functions/README.md | 10 +++++++--- .../DurableTaskResourceExtensionsTests.cs | 2 -- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json index 5b99a0a49ae..fa595ad7768 100644 --- a/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json +++ b/playground/AzureFunctionsWithDts/AzureFunctionsWithDts.Functions/Properties/launchSettings.json @@ -1,6 +1,6 @@ { "profiles": { - "AzureFunctionsEndToEnd_Functions": { + "AzureFunctionsWithDts_Functions": { "commandName": "Project", "commandLineArgs": "--port 7071", "launchBrowser": false diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index ca9834c1dd4..9f8e85f6ca3 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -213,7 +213,9 @@ public static IResourceBuilder AddTaskHub(this IResource await notifications.PublishUpdateAsync(r, snapshot => snapshot with { State = KnownResourceStates.Running, - Urls = [new("dashboard", url ?? throw new InvalidOperationException("URL cannot be null"), false) { DisplayProperties = new() { DisplayName = "Task Hub Dashboard" } }] + Urls = url is not null + ? [new("dashboard", url, false) { DisplayProperties = new() { DisplayName = "Task Hub Dashboard" } }] + : [] }).ConfigureAwait(false); }); } diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs index 24ae82e76bd..49b62424fdf 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskSchedulerResource.cs @@ -44,7 +44,7 @@ private ReferenceExpression CreateConnectionString() }; } - throw new InvalidOperationException("Unable to create the Durable Task Scheduler connection string."); + throw new InvalidOperationException($"Unable to resolve the Durable Task Scheduler connection string. Configure the scheduler using {nameof(DurableTaskResourceExtensions.RunAsEmulator)}() or {nameof(DurableTaskResourceExtensions.RunAsExisting)}(connectionString) before accessing {nameof(ConnectionStringExpression)}."); } private ReferenceExpression CreateDashboardEndpoint() diff --git a/src/Aspire.Hosting.Azure.Functions/README.md b/src/Aspire.Hosting.Azure.Functions/README.md index deb3d9e59a7..266cfd7618c 100644 --- a/src/Aspire.Hosting.Azure.Functions/README.md +++ b/src/Aspire.Hosting.Azure.Functions/README.md @@ -81,9 +81,9 @@ builder.Build().Run(); When a Scheduler runs as an emulator, Aspire automatically provides: -* A "Scheduler Dashboard" URL for the scheduler resource. -* A "Task Hub Dashboard" URL for each Task Hub resource. -* A `DTS_TASK_HUB_NAMES` environment variable on the emulator container listing the Task Hub names associated with that scheduler. +- A "Scheduler Dashboard" URL for the scheduler resource. +- A "Task Hub Dashboard" URL for each Task Hub resource. +- A `DTS_TASK_HUB_NAMES` environment variable on the emulator container listing the Task Hub names associated with that scheduler. ### Use an existing Scheduler @@ -100,6 +100,10 @@ var scheduler = builder.AddDurableTaskScheduler("scheduler") var taskHubName = builder.AddParameter("taskhub-name", "mytaskhub"); var taskHub = scheduler.AddTaskHub("taskhub").WithTaskHubName(taskHubName); ``` +## Additional documentation + +- https://learn.microsoft.com/azure/azure-functions +- https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler ## Feedback & contributing diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs index 2a8973d452f..b5921fe5894 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -73,8 +73,6 @@ public async Task AddDurableTaskHub_RunAsExisting_ResolvedConnectionStringParame string expectedConnectionString = $"{dtsConnectionString};TaskHub={expectedTaskHubName}"; using var builder = TestDistributedApplicationBuilder.Create(); - var connectionStringParameter = builder.AddParameter("dts-connection-string", expectedConnectionString); - var dts = builder .AddDurableTaskScheduler("dts") .RunAsExisting(dtsConnectionString); From 791e27eed07fef78c78ab907a92e393c2ce3dd6a Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Wed, 7 Jan 2026 09:53:34 -0800 Subject: [PATCH 16/19] Fixup test. --- .../DurableTaskResourceExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs index b5921fe5894..d16de89d3a0 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -126,7 +126,7 @@ public void RunAsExisting_InPublishMode_DoesNotApplyConnectionStringAnnotation() Assert.True(dts.ApplicationBuilder.ExecutionContext.IsPublishMode); var ex = Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); - Assert.Contains("Unable to create the Durable Task Scheduler connection string", ex.Message); + Assert.Contains("Unable to resolve the Durable Task Scheduler connection string", ex.Message); } [Fact] From d0d14d651c325edd78b99cf68797511fd559624e Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Wed, 7 Jan 2026 09:55:13 -0800 Subject: [PATCH 17/19] Fixup tests again. --- .../DurableTaskResourceExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs index d16de89d3a0..71e23e99523 100644 --- a/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/DurableTaskResourceExtensionsTests.cs @@ -251,6 +251,6 @@ public void DurableTaskSchedulerResource_WithoutEmulatorOrExistingConnectionStri var dts = builder.AddDurableTaskScheduler("dts"); var ex = Assert.Throws(() => _ = dts.Resource.ConnectionStringExpression); - Assert.Contains("Unable to create the Durable Task Scheduler connection string", ex.Message); + Assert.Contains("Unable to resolve the Durable Task Scheduler connection string", ex.Message); } } From 4f8a4bf468d970ac3676f882ca9acf70faba79fe Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Wed, 14 Jan 2026 09:14:17 -0800 Subject: [PATCH 18/19] Move examples outside of remarks. --- .../DurableTaskResourceExtensions.cs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 9f8e85f6ca3..3ac4e6e4ca9 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -18,7 +18,6 @@ public static class DurableTaskResourceExtensions /// The distributed application builder. /// The logical name of the scheduler resource. /// An for the scheduler resource. - /// /// /// Add a Durable Task scheduler resource: /// @@ -26,7 +25,6 @@ public static class DurableTaskResourceExtensions /// var scheduler = builder.AddDurableTaskScheduler("scheduler"); /// /// - /// public static IResourceBuilder AddDurableTaskScheduler(this IDistributedApplicationBuilder builder, string name) { var scheduler = new DurableTaskSchedulerResource(name); @@ -44,9 +42,8 @@ public static IResourceBuilder AddDurableTaskSched /// The connection string referencing the existing Durable Task scheduler instance. /// The same instance for fluent chaining. /// - /// /// The existing resource annotation is only applied when the execution context is not in publish mode. - /// + /// /// /// Use an existing scheduler instead of provisioning a new one: /// @@ -55,7 +52,6 @@ public static IResourceBuilder AddDurableTaskSched /// .RunAsExisting("Endpoint=https://example;...;"); /// /// - /// public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, string connectionString) { if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) @@ -74,9 +70,8 @@ public static IResourceBuilder RunAsExisting(this /// The connection string parameter referencing the existing Durable Task scheduler instance. /// The same instance for fluent chaining. /// - /// /// The existing resource annotation is only applied when the execution context is not in publish mode. - /// + /// /// /// Use an existing scheduler where the connection string is supplied via a parameter: /// @@ -87,7 +82,6 @@ public static IResourceBuilder RunAsExisting(this /// .RunAsExisting(schedulerConnectionString); /// /// - /// public static IResourceBuilder RunAsExisting(this IResourceBuilder builder, IResourceBuilder connectionString) { if (!builder.ApplicationBuilder.ExecutionContext.IsPublishMode) @@ -104,7 +98,6 @@ public static IResourceBuilder RunAsExisting(this /// The resource builder for the scheduler. /// Callback that exposes underlying container used for emulation to allow for customization. /// The same instance for chaining. - /// /// /// Run the scheduler locally using the emulator: /// @@ -113,7 +106,6 @@ public static IResourceBuilder RunAsExisting(this /// .RunAsEmulator(); /// /// - /// public static IResourceBuilder RunAsEmulator(this IResourceBuilder builder, Action>? configureContainer = null) { ArgumentNullException.ThrowIfNull(builder); @@ -181,7 +173,6 @@ public static IResourceBuilder RunAsEmulator(this /// The scheduler resource builder. /// The logical name of the task hub resource. /// An for the task hub resource. - /// /// /// Add a task hub under a scheduler: /// @@ -192,7 +183,6 @@ public static IResourceBuilder RunAsEmulator(this /// .WithTaskHubName("MyTaskHub"); /// /// - /// public static IResourceBuilder AddTaskHub(this IResourceBuilder builder, string name) { var hub = new DurableTaskHubResource(name, builder.Resource); @@ -229,7 +219,6 @@ await notifications.PublishUpdateAsync(r, snapshot => snapshot with /// The task hub resource builder. /// The name of the Task Hub. /// The same instance for fluent chaining. - /// /// /// Set the task hub name: /// @@ -238,7 +227,6 @@ await notifications.PublishUpdateAsync(r, snapshot => snapshot with /// var hub = scheduler.AddTaskHub("hub").WithTaskHubName("MyTaskHub"); /// /// - /// public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, string taskHubName) { return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName)); @@ -250,7 +238,6 @@ public static IResourceBuilder WithTaskHubName(this IRes /// The task hub resource builder. /// A parameter resource that resolves to the Task Hub name. /// The same instance for fluent chaining. - /// /// /// Set the task hub name from a parameter: /// @@ -261,7 +248,6 @@ public static IResourceBuilder WithTaskHubName(this IRes /// var hub = scheduler.AddTaskHub("hub").WithTaskHubName(taskHubName); /// /// - /// public static IResourceBuilder WithTaskHubName(this IResourceBuilder builder, IResourceBuilder taskHubName) { return builder.WithAnnotation(new DurableTaskHubNameAnnotation(taskHubName.Resource)); From 7261bed871957b6c65d11b1455cba3ce6e92febb Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Wed, 14 Jan 2026 09:47:17 -0800 Subject: [PATCH 19/19] Defer testing for emulator until running. --- .../DurableTaskResourceExtensions.cs | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs index 3ac4e6e4ca9..21b5558d8e9 100644 --- a/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs +++ b/src/Aspire.Hosting.Azure.Functions/DurableTask/DurableTaskResourceExtensions.cs @@ -191,24 +191,23 @@ public static IResourceBuilder AddTaskHub(this IResource var hubBuilder = builder.ApplicationBuilder.AddResource(hub); - if (builder.Resource.IsEmulator) - { - hubBuilder.OnResourceReady( - async (r, e, ct) => - { - var notifications = e.Services.GetRequiredService(); + hubBuilder.OnResourceReady( + async (r, e, ct) => + { + var notifications = e.Services.GetRequiredService(); - var url = await ReferenceExpression.Create($"{r.Parent.EmulatorDashboardEndpoint}/subscriptions/default/schedulers/default/taskhubs/{r.TaskHubName}").GetValueAsync(ct).ConfigureAwait(false); + var url = builder.Resource.IsEmulator + ? await ReferenceExpression.Create($"{r.Parent.EmulatorDashboardEndpoint}/subscriptions/default/schedulers/default/taskhubs/{r.TaskHubName}").GetValueAsync(ct).ConfigureAwait(false) + : null; - await notifications.PublishUpdateAsync(r, snapshot => snapshot with - { - State = KnownResourceStates.Running, - Urls = url is not null - ? [new("dashboard", url, false) { DisplayProperties = new() { DisplayName = "Task Hub Dashboard" } }] - : [] - }).ConfigureAwait(false); - }); - } + await notifications.PublishUpdateAsync(r, snapshot => snapshot with + { + State = KnownResourceStates.Running, + Urls = url is not null + ? [new("dashboard", url, false) { DisplayProperties = new() { DisplayName = "Task Hub Dashboard" } }] + : [] + }).ConfigureAwait(false); + }); return hubBuilder; }